nobrainer-tech-flow 2.6.0 → 2.7.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 (33) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/AGENTS.md +8 -8
  5. package/CLAUDE.md +8 -8
  6. package/README.md +72 -34
  7. package/adapters/bootstrap.md +2 -2
  8. package/assets/nobrainer-workflow.svg +2 -2
  9. package/docs/CHAT_PLUGINS.md +4 -2
  10. package/docs/INSTALL.md +24 -14
  11. package/docs/SKILL_CURATION.md +6 -6
  12. package/docs/releases/v2.7.0.md +145 -0
  13. package/gemini-extension.json +1 -1
  14. package/package.json +1 -1
  15. package/plugin.json +1 -1
  16. package/scripts/install.sh +1 -1
  17. package/scripts/install_guards.py +29 -13
  18. package/scripts/install_personalization.py +1 -1
  19. package/skills/nobrainer-auto-fine-tune/SKILL.md +1 -1
  20. package/skills/nobrainer-build/SKILL.md +11 -0
  21. package/skills/nobrainer-security/SKILL.md +10 -0
  22. package/skills/nobrainer-team/SKILL.md +43 -64
  23. package/skills/nobrainer-team/references/team-plan.md +7 -6
  24. package/skills/nobrainer-tech-flow/SKILL.md +13 -13
  25. package/skills/nobrainer-tech-flow/VERSION +1 -1
  26. package/skills/nobrainer-tech-flow/references/correction-hooks.md +11 -5
  27. package/skills/nobrainer-tech-flow/references/delivery.md +44 -29
  28. package/skills/nobrainer-tech-flow/references/long-run-state.md +31 -5
  29. package/skills/nobrainer-tech-flow/references/model-routing.md +13 -11
  30. package/skills/nobrainer-tech-flow/references/project-start.md +2 -2
  31. package/skills/nobrainer-tech-flow/references/routing.md +6 -8
  32. package/skills/nobrainer-tech-flow/references/setup.md +6 -7
  33. package/skills/nobrainer-tech-flow/scripts/flow_guard.py +179 -2
@@ -20,8 +20,11 @@ the whole goal open while a required item remains, and choose the next ready
20
20
  unit as each stage closes. Do not invent an LDD method when the acronym or
21
21
  source is unresolved.
22
22
 
23
- A native goal is separate: create it only when explicitly requested and supported
24
- under the host tool contract. Reuse an existing goal; never replace an unfinished
23
+ When the host offers a native goal, set the outcome and DoD there for substantial
24
+ work under the host tool contract, so the owner can see whether the goal is running,
25
+ paused or stopped. Keep the TODO visible too: mirror the canonical TODO in the
26
+ host's plan or todo tool when it has one, otherwise show the Progress checklist.
27
+ Reuse an existing goal; never replace an unfinished
25
28
  goal or invent a token budget. Read back native goal creation before claiming it.
26
29
  A Markdown goal does not promise scheduled continuation or automatically press
27
30
  START. When START is owner-only, explain that requirement once; never claim it
@@ -117,9 +120,12 @@ useful authorized work remains. Never turn an unknown right/license into consent
117
120
 
118
121
  Scale verification to blast radius. A reversible, low-risk change may proceed on
119
122
  green automated tests, review and security checks. CI configuration, credentials,
120
- security-sensitive paths, data migrations and irreversible effects need an
121
- independent review and remain owner-gated. An agent's approval never counts as the
122
- owner's.
123
+ security-sensitive paths (such as authentication, permissions, payments and personal
124
+ data), data migrations and irreversible effects need an independent review and
125
+ remain owner-gated, however small the diff. Work from an untrusted or unknown author
126
+ never takes the low-risk path. A user-interface change needs rendered evidence, a
127
+ screenshot or recording, before and after where possible; unit tests alone do not
128
+ prove it. An agent's approval never counts as the owner's.
123
129
 
124
130
  Review the next executable slice against concrete acceptance and failure paths.
125
131
  For a stable slice, default to one review and one focused correction verification.
@@ -142,27 +148,36 @@ reviews, tests or generated files.
142
148
  ## Coordinated team execution
143
149
 
144
150
  MAIN owns the full outcome, coordination, critical path, dependencies, integration,
145
- owner decisions and acceptance. Delegation is the default route to a faster done:
146
- MAIN works its own unit while workers run, and keeps only tiny, tightly coupled,
147
- gated or shared-state steps. Delegate a unit when it is independent and large
148
- enough that parallel work shortens the time to done more than a worker's start-up
149
- costs. Give each worker a short task packet, not the whole conversation, and ask
150
- for a concise report. Send narrow, mechanical work to a cheaper model under the
151
- host's policy. Each worker's model and effort come from that policy; the workflow
152
- names no model. Before executing non-trivial work or entering a
153
- blocking wait, identify independent ready units and dispatch every useful eligible
154
- unit promptly to native workers within verified host capacity and authorized usage
155
- limits. Eligibility requires accepted dependencies, authority, a supported route
156
- and a free write scope. One visible MAIN coordinator is compatible with many native
157
- workers. Before a large or risky multi-writer fan-out that is not already
158
- executable, run the [builder-readiness check](idea-to-delivery.md#builder-readiness-check);
159
- skip it for already-executable plans and mechanical changes.
160
-
161
- When an eligible ready unit stays in MAIN while a useful slot is free, record the
162
- concrete reason in the current plan: shared mutable state, missing accepted
163
- dependency, unavailable capability/route, authority gate, usage limit, or a tiny or
164
- tightly coupled step. A missing measured speedup is not by itself a reason to keep
165
- eligible work in MAIN.
151
+ owner decisions and acceptance. The default is one MAIN loop that loads skills on
152
+ demand and runs independent tool calls in parallel. A worker costs several times the
153
+ tokens of the same work in one loop, every handoff loses detail, and parallel workers
154
+ often finish later than one loop would. Delegate a unit to a native worker only when
155
+ at least one condition holds and outweighs that cost:
156
+
157
+ - parallel work: the unit is independent, substantial and needs little of MAIN's
158
+ context, so a fresh, discardable context covers it sooner or more thoroughly;
159
+ - context isolation: the unit would flood MAIN with output it mostly does not need,
160
+ such as a wide search, a long log or a large read, and can return a compact result;
161
+ - independent verification: a reviewer that should not share the author's reasoning;
162
+ - specialization: the unit needs tools or instructions that conflict with MAIN's.
163
+
164
+ Name the condition for each delegated unit in the current plan; without one, the
165
+ unit stays in MAIN. Split by context, not by type of work: the worker that changes a
166
+ feature also writes and runs its tests, and planning, implementation and testing of
167
+ one unit are not split across workers. Give each worker a short task packet, not the
168
+ whole conversation, and ask for a concise report. Send narrow, mechanical work to a
169
+ cheaper model under the host's policy. Each worker's model and effort come from that
170
+ policy; the workflow names no model. When several units qualify, dispatch them
171
+ together within verified host capacity and authorized usage limits, before executing
172
+ non-trivial work or entering a blocking wait. Eligibility also requires accepted
173
+ dependencies, authority, a supported route and a free write scope. One visible MAIN
174
+ coordinator is compatible with many native workers. Before a large or risky
175
+ multi-writer fan-out that is not already executable, run the
176
+ [builder-readiness check](idea-to-delivery.md#builder-readiness-check); skip it for
177
+ already-executable plans and mechanical changes.
178
+
179
+ When an owner or host rule requires more delegation than these conditions give, that
180
+ rule controls; record it in the plan.
166
181
 
167
182
  A single bounded native delegate needs no Dispatcher queue; MAIN keeps coordination
168
183
  and acceptance. Use the [small native-worker packet](communication.md#small-native-worker-packet).
@@ -172,7 +187,7 @@ Sessions/Dispatcher contract. Visible conversations follow the
172
187
  them for unavailable native workers.
173
188
 
174
189
  Target concurrency is the minimum of the effective host/model limit, authorized
175
- usage limits and useful independent ready units. A configured ceiling is not proof
190
+ usage limits and the units that qualify for delegation. A configured ceiling is not proof
176
191
  of available runtime slots. Count running reviewers, existing live workers and any
177
192
  permitted descendants too. If capacity is unknown, dispatch ready units
178
193
  incrementally within host policy and remaining quota until a capacity refusal;
@@ -253,6 +268,6 @@ selects the next ready work without waiting for a new user prompt. Independent
253
268
  milestones may run together up to the target concurrency in
254
269
  [coordinated team execution](#coordinated-team-execution). Keep one overarching goal open
255
270
  until every required milestone and integrated DoD passes. Do not complete and
256
- replace the native goal for each milestone. Native goals are optional mirrors;
257
- Markdown remains the portable record. Host pauses or quotas can suspend execution,
271
+ replace the native goal for each milestone. Native goals mirror the DoD where the host
272
+ offers them; Markdown remains the portable record. Host pauses or quotas can suspend execution,
258
273
  so preserve the next action without promising an automatic wakeup.
@@ -7,7 +7,9 @@ ordinary single-session work.
7
7
  ## One canonical ledger
8
8
 
9
9
  Use the project's existing Markdown tracker when suitable. Otherwise create one
10
- small task-local Markdown artifact. It is the only mutable owner of execution state;
10
+ small task-local Markdown `GOAL_FILE`, by default `.nobrainer/goal.md`, which the
11
+ context inventory already searches; do not commit it to a public repository.
12
+ It is the only mutable owner of execution state;
11
13
  specifications define required truth, reports provide evidence, and the wiki
12
14
  stores durable knowledge.
13
15
 
@@ -54,6 +56,7 @@ GOAL_ID: <stable identity>
54
56
  OUTCOME: <observable result>
55
57
  NON_GOALS: <bounded exclusions>
56
58
  DOD: <acceptance checks>
59
+ CURRENT_MILESTONE: <short-term goal in progress, its acceptance, the OUTCOME it serves>
57
60
  STATUS: ACTIVE | OWNER_DECISION_REQUIRED | COMPLETE | BLOCKED
58
61
  CHECKPOINT: <last verified state and evidence pointer>
59
62
  NEXT_SAFE_ACTION: <one action or NONE>
@@ -61,9 +64,16 @@ SESSION_POLICY: <policy reference or NONE>
61
64
  LAST_HEALTH_READBACK: <event, result, evidence pointer>
62
65
  ```
63
66
 
64
- The Markdown goal works without any native goal API. A host-native goal is an
65
- optional mirror and control handle, used only when available and authorized by
66
- the host's tool rules; never create it merely because this reference was loaded.
67
+ Keep one `CURRENT_MILESTONE`: a short-term goal derived from `OUTCOME` and the open
68
+ request rows, small enough to finish and verify in the current session. Work toward
69
+ it without waiting for the owner. When it is accepted, record its evidence, set the
70
+ next milestone from the open rows and continue; accepting a milestone never closes
71
+ `OUTCOME`. When the owner changes a decision or a new request changes what matters
72
+ most now, re-derive the milestone at once.
73
+
74
+ The Markdown goal works without any native goal API. A host-native goal mirrors
75
+ the outcome and DoD where the host offers one and its tool rules allow it; it is a
76
+ visible control handle, never the canonical state.
67
77
  After session start, compaction or a verified clear, read `GOAL_FILE` from disk,
68
78
  verify `GOAL_ID`, repository, checkout, HEAD/dirty state, status and evidence,
69
79
  then reconcile any native goal actually used and continue from `NEXT_SAFE_ACTION`. A
@@ -72,6 +82,22 @@ The same file owns the outcome-level TODO rows; health gates may checkpoint them
72
82
  but cannot mark work complete. On every material transition, update the current
73
83
  row, evidence and next safe action before starting another unit.
74
84
 
85
+ ## New owner requests
86
+
87
+ A request held only in the transcript can be lost to compaction. While the ledger
88
+ is active, record each new owner request or idea as soon as it arrives, before
89
+ continuing any work, even when it is deferred: append a row whose `Outcome` quotes
90
+ the owner's words, minus any secret, with the date (`YYYY-MM-DD`) and status
91
+ `PENDING`, or `READY` when nothing blocks it. When the gate first passes
92
+ mid-conversation, backfill rows for earlier unfinished requests. A request that
93
+ changes an earlier decision also applies `OWNER_DECISION_CHANGED` at once, including
94
+ any instruction-file line that still states the old decision.
95
+
96
+ At every checkpoint, after compaction and at closeout, reconcile each row against
97
+ current evidence. No row is silently dropped: closeout leaves each one `ACCEPTED`,
98
+ `STOPPED` with the owner decision that stopped it, or visibly open as `BLOCKED` or
99
+ `OWNER_DECISION_REQUIRED` with its next step named in the final report.
100
+
75
101
  ## Recovery card
76
102
 
77
103
  For unattended or resumable work, record:
@@ -158,7 +184,7 @@ Create a durable visible session only under the
158
184
  Use native subagents for delegated units; if unavailable, continue in MAIN.
159
185
  For a persistent delegated queue:
160
186
 
161
- 1. `nobrainer-team` maps every independent ready unit to a bounded native assignment.
187
+ 1. `nobrainer-team` maps each unit that qualifies for delegation to a bounded native assignment.
162
188
  2. If several delegated units form a queue, `nobrainer-dispatcher` computes the
163
189
  ready set and bounded batch.
164
190
  3. MAIN spawns each bounded unit as a native subagent; only for owner-authorized
@@ -27,17 +27,18 @@ ROUTE_REASON: <risk, complexity, latency or evidence reason>
27
27
 
28
28
  - If no policy is supplied, keep the owner- or host-selected MAIN model.
29
29
  Use `STANDARD` for a clear one-unit task or a client without native
30
- workers. Use `ROUTED` by default for substantial work with useful
31
- independent units and verified native worker support.
32
- - `STANDARD` uses the owner- or host-selected model and effort. It is the fallback
33
- for small work, unavailable native workers or one-model hosts. Unknown capacity
30
+ workers. Use `ROUTED` only for units that qualify for delegation under
31
+ [team execution](delivery.md#coordinated-team-execution) on a host with
32
+ verified native worker support.
33
+ - `STANDARD` uses the owner- or host-selected model and effort. It is the default,
34
+ and the only policy for small work, unavailable native workers or one-model hosts. Unknown capacity
34
35
  permits bounded incremental dispatch under the delivery contract.
35
36
  - `EXTENDED` keeps that identity while allowing a larger declared
36
37
  effort, context, time or cost budget and bounded extra verification. A
37
38
  stronger model is an explicit policy change with an exact model and gate; it
38
39
  is never a hidden retry.
39
- - `ROUTED` is the bounded default for useful parallel work: it selects a
40
- capable worker for each independent unit from models the host actually
40
+ - `ROUTED` applies to units that qualify for delegation: it selects a
41
+ capable worker for each such unit from models the host actually
41
42
  exposes. Prefer a smaller, quicker route when the task's acceptance can
42
43
  still be met. Record the exact model, effort and budget
43
44
  before dispatch. If the target is unavailable, use an already verified
@@ -46,15 +47,16 @@ ROUTE_REASON: <risk, complexity, latency or evidence reason>
46
47
 
47
48
  ## Routing heuristics
48
49
 
49
- These heuristics choose a worker route; they do not gate eligible dispatch under
50
- [team execution](delivery.md#coordinated-team-execution).
50
+ These heuristics choose a worker route for a unit that already qualifies under
51
+ [team execution](delivery.md#coordinated-team-execution); they do not make a unit
52
+ qualify.
51
53
 
52
- - For each independent ready unit, choose a smaller or faster worker only when
54
+ - For each qualifying unit, choose a smaller or faster worker only when
53
55
  the current host advertises it, the assignment accepts it, and the expected
54
56
  setup plus audit cost is lower than the critical-path benefit. Use measured
55
57
  cost and latency when available; otherwise mark them unknown.
56
- - Default to useful native parallelism when the task splits cleanly. A second
57
- perspective may catch defects, and a smaller worker may avoid repeating
58
+ - When the task splits cleanly by context, parallel workers are one option to
59
+ weigh against one MAIN loop. A second perspective may catch defects, and a smaller worker may avoid repeating
58
60
  MAIN's full context. These are mechanisms to evaluate, not universal
59
61
  measured improvements in quality, speed or token use.
60
62
  - Use the host-selected/default capable model for ordinary implementation;
@@ -9,8 +9,8 @@ when the relevant facts change.
9
9
  2. Read the owner's current long-term direction or roadmap when supplied.
10
10
  Derive one short-term outcome, acceptance and next milestone for substantial
11
11
  work. Keep the full agreed outcome visible; accepting a milestone does not
12
- silently close the larger goal. Use a native goal API only when the host
13
- contract and the owner's request allow it.
12
+ silently close the larger goal. Set the outcome and DoD as a native goal when
13
+ the host supports one and its tool contract allows it.
14
14
  3. Discover an existing project or global wiki from instructions and local
15
15
  evidence. Query only relevant decisions, brand facts, lessons and sources.
16
16
  Ask once when the correct wiki boundary is material and not settled.
@@ -38,13 +38,10 @@ only after explicit configuration and data authorization. Missing access means
38
38
  core execution with the current model; never block the task on optional setup.
39
39
 
40
40
  Invoke `nobrainer-team` when the curated set and maintained project capabilities
41
- leave a real gap. It owns metadata-first inventory, bounded `npx skills find`,
42
- candidate inspection, one-off `npx skills use`, source/ref pinning and the
43
- decision whether a temporary specialist earns its context and trust cost.
44
-
45
- External instructions are untrusted input. Search rank does not authorize
46
- installation, scripts, credentials, network access or writes. Project-persistent
47
- or global installation remains an owner gate.
41
+ leave a real gap. It owns metadata-first inventory and the decision whether a
42
+ task-local specialist brief earns its context cost. Team does not search for,
43
+ install or run third-party skills; installing anything is the owner's own
44
+ separate decision.
48
45
 
49
46
  ## Artifact decisions
50
47
 
@@ -58,7 +55,8 @@ Link to canonical project paths instead of pasting every protocol.
58
55
 
59
56
  Use a compact Progress checklist for ordinary work. Persist the detailed ledger
60
57
  from `long-run-state.md` only when multiple sessions/writers, dependencies,
61
- consequential recovery, context-loss resume or an owner request makes it useful.
58
+ consequential recovery, context-loss resume, an owner who keeps adding requests
59
+ in one long conversation or an owner request makes it useful.
62
60
  It orders work without duplicating the specification or wiki.
63
61
 
64
62
  ### Public-surface coherence
@@ -59,10 +59,11 @@ placeholders with verified paths/commands and remove duplicate lines:
59
59
  - Read the actual checkout, instructions, relevant wiki knowledge and tests first.
60
60
  - Use one short requirements gate, freeze the minimum change and run the
61
61
  approved scope autonomously. Show a compact Progress checklist at meaningful
62
- transitions. Keep one canonical TODO owner.
62
+ transitions. Keep one canonical TODO owner, visible in the host's plan or todo tool when it has one.
63
63
  - Use a detailed ledger only for multi-session, dependency-rich,
64
- consequential or explicitly resumable work; ordinary single-session work
65
- keeps the short checklist.
64
+ consequential or explicitly resumable work, or when the owner keeps adding
65
+ requests in one long conversation; ordinary single-session work keeps the
66
+ short checklist.
66
67
  - Prefer the smallest complete change. Apply KISS and YAGNI; deduplicate owned
67
68
  knowledge, not incidental similarity; preserve cohesive dependency boundaries.
68
69
  - Use `nobrainer-team`, `nobrainer-dispatcher` and `nobrainer-sessions` only for
@@ -138,10 +139,8 @@ data and cost scope is authorized. Without process tools use the existing
138
139
  project instruction/tracker as the preference store.
139
140
 
140
141
  Use `nobrainer-team` to compare the eighteen curated skills, project-native tools
141
- and active runtime. A missing specialist may be evaluated temporarily through
142
- the open skills ecosystem only after source/ref, instructions, scripts, license,
143
- permissions, network/credential behavior, trigger overlap and rollback are
144
- reviewed. Persistent/global installation is an owner gate.
142
+ and active runtime. For a missing specialist, Team writes a task-local brief for
143
+ a native worker; it never searches for, installs or runs third-party skills.
145
144
 
146
145
  ## 5. Choose justified artifacts and sessions
147
146
 
@@ -5,6 +5,9 @@
5
5
  shell command; it never denies. ``stop`` reads a Stop or SubagentStop payload and
6
6
  blocks the stop once when a turn that edited files or ran commands ends without a
7
7
  proof line. Both fail open: input they cannot read is allowed, with a note on stderr.
8
+ ``session`` reads a SessionStart payload and, when the project has active Flow goal
9
+ files, tells the agent to re-read them. It prints only their paths and a known STATUS
10
+ word, never other goal-file text, and prints nothing at all on any error.
8
11
 
9
12
  This is a seatbelt, not a security boundary. It reads the command text, so a
10
13
  command hidden behind ``sh -c``, a variable or an alias can pass unnoticed, and a
@@ -16,10 +19,13 @@ https://code.claude.com/docs/en/hooks.
16
19
 
17
20
  from __future__ import annotations
18
21
 
22
+ import fnmatch
23
+ import itertools
19
24
  import json
20
25
  import os
21
26
  import re
22
27
  import shlex
28
+ import stat
23
29
  import sys
24
30
  import tempfile
25
31
  from pathlib import Path
@@ -67,6 +73,31 @@ STOP_REASON = (
67
73
  "proof. Add a line starting with 'Proof:' or 'Checked:' that names the command, test "
68
74
  "or readback showing the result, or say UNVERIFIED and what is missing."
69
75
  )
76
+ # A goal file as nobrainer-context's context_inventory.goal_state finds it: Markdown with a
77
+ # GOAL_ID line whose first STATUS is not COMPLETE, in the project root or one or two levels
78
+ # into tasks/, docs/ or .nobrainer/. tests/test_flow_guard.py checks that both agree.
79
+ # Unlike goal_state, the guard follows no symlink, so it never reads outside the project,
80
+ # and stops after MAX_ENTRIES folder entries or MAX_READS files, so it stays fast.
81
+ GOAL_ID = re.compile(r"^GOAL_ID:\s*\S", re.MULTILINE)
82
+ GOAL_STATUS = re.compile(r"^STATUS:\s*([A-Z_]+)", re.MULTILINE)
83
+ GOAL_DIRS = ("", "tasks", "docs", ".nobrainer")
84
+ GOAL_MAX_BYTES = 256 * 1024
85
+ MAX_ENTRIES = 5000
86
+ MAX_READS = 200
87
+ FOLDER_FLAGS = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_NOFOLLOW", 0)
88
+ # Non-blocking, so a FIFO swapped in for a goal file cannot hang the hook.
89
+ FILE_FLAGS = os.O_RDONLY | getattr(os, "O_NONBLOCK", 0) | getattr(os, "O_NOFOLLOW", 0)
90
+ # The goal file is repository content, so none of its text reaches the context: only a
91
+ # STATUS from references/long-run-state.md, and only a path made of these characters.
92
+ KNOWN_STATUSES = ("ACTIVE", "BLOCKED", "OWNER_DECISION_REQUIRED")
93
+ PATH_PART = re.compile(r"[A-Za-z0-9._-]+")
94
+ MAX_PATH_CHARS = 200
95
+ MAX_GOALS_SHOWN = 3
96
+ SESSION_INTRO = (
97
+ "Flow guard: the context was compacted, resumed or cleared. Before continuing, re-read "
98
+ "these active Flow goal files from disk and check GOAL_ID, STATUS, CHECKPOINT and "
99
+ "NEXT_SAFE_ACTION against the repository. Only paths and known STATUS words are shown here."
100
+ )
70
101
 
71
102
 
72
103
  def note(message: str) -> None:
@@ -229,11 +260,157 @@ def stop(payload: dict) -> dict | None:
229
260
  return {"decision": "block", "reason": STOP_REASON}
230
261
 
231
262
 
263
+ def project_root(cwd: Path) -> Path:
264
+ """The nearest folder holding .git, where context_inventory looks too; else CWD.
265
+
266
+ Only a missing .git moves the search up; any other error propagates.
267
+ """
268
+
269
+ for folder in (cwd, *cwd.parents):
270
+ try:
271
+ os.stat(folder / ".git")
272
+ except FileNotFoundError:
273
+ continue
274
+ return folder
275
+ return cwd
276
+
277
+
278
+ def goal_status(folder: int, name: str) -> str | None:
279
+ """The STATUS of goal file NAME in the open FOLDER, UNKNOWN when it names none.
280
+
281
+ None when NAME is not a regular file of at most GOAL_MAX_BYTES with a GOAL_ID line.
282
+ """
283
+
284
+ with os.fdopen(os.open(name, FILE_FLAGS, dir_fd=folder), "rb") as handle:
285
+ if not stat.S_ISREG(os.fstat(handle.fileno()).st_mode):
286
+ return None
287
+ data = handle.read(GOAL_MAX_BYTES + 1)
288
+ text = data.decode("utf-8", errors="replace")
289
+ if len(data) > GOAL_MAX_BYTES or not GOAL_ID.search(text):
290
+ return None
291
+ status = GOAL_STATUS.search(text)
292
+ return status.group(1) if status else "UNKNOWN"
293
+
294
+
295
+ def active_goals(root: Path) -> list[tuple[Path, str]]:
296
+ """(path, STATUS) of each active goal file, in goal_state order, within the bounds.
297
+
298
+ Every folder and file is opened relative to its open parent and never through a
299
+ symlink, so nothing outside ROOT is listed or read even if the tree changes meanwhile.
300
+ Errors propagate, so the caller says nothing.
301
+ """
302
+
303
+ budget = {"entries": MAX_ENTRIES, "reads": MAX_READS}
304
+ goals: list[tuple[Path, str]] = []
305
+
306
+ def listing(folder: int) -> list[os.DirEntry]:
307
+ found = []
308
+ if budget["entries"] > 0:
309
+ with os.scandir(folder) as entries:
310
+ for entry in entries:
311
+ budget["entries"] -= 1
312
+ if not entry.is_symlink():
313
+ found.append(entry)
314
+ if budget["entries"] <= 0:
315
+ break
316
+ return sorted(found, key=lambda entry: entry.name)
317
+
318
+ def walk(folder: int, path: Path, nested: bool) -> None:
319
+ # Files first, then each subfolder in name order: the order goal_state sorts into.
320
+ entries = listing(folder)
321
+ for entry in entries:
322
+ if not fnmatch.fnmatchcase(entry.name, "*.md") or not entry.is_file(follow_symlinks=False):
323
+ continue
324
+ if budget["reads"] <= 0:
325
+ return
326
+ budget["reads"] -= 1
327
+ status = goal_status(folder, entry.name)
328
+ if status is not None and status != "COMPLETE":
329
+ goals.append((path / entry.name, status))
330
+ for entry in entries if nested else ():
331
+ if entry.is_dir(follow_symlinks=False) and budget["entries"] > 0:
332
+ inner = os.open(entry.name, FOLDER_FLAGS, dir_fd=folder)
333
+ try:
334
+ walk(inner, path / entry.name, False)
335
+ finally:
336
+ os.close(inner)
337
+
338
+ top = os.open(root, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
339
+ try:
340
+ for sub in GOAL_DIRS:
341
+ if not sub:
342
+ walk(top, root, False)
343
+ continue
344
+ try:
345
+ mode = os.stat(sub, dir_fd=top, follow_symlinks=False).st_mode
346
+ except FileNotFoundError:
347
+ continue
348
+ # A tasks/, docs/ or .nobrainer/ that is a symlink or a file is not looked into.
349
+ if stat.S_ISDIR(mode) and budget["entries"] > 0:
350
+ folder = os.open(sub, FOLDER_FLAGS, dir_fd=top)
351
+ try:
352
+ walk(folder, root / sub, True)
353
+ finally:
354
+ os.close(folder)
355
+ finally:
356
+ os.close(top)
357
+ return goals
358
+
359
+
360
+ def shown_path(path: Path, cwd: Path) -> str | None:
361
+ """PATH relative to CWD when every part is plain and the whole is short, else None."""
362
+
363
+ relative = os.path.relpath(path, cwd)
364
+ parts = relative.split(os.sep)
365
+ named = list(itertools.dropwhile(lambda part: part == "..", parts))
366
+ if len(relative) > MAX_PATH_CHARS or not named:
367
+ return None
368
+ if not all(PATH_PART.fullmatch(part) and part not in (".", "..") for part in named):
369
+ return None
370
+ return "/".join(parts)
371
+
372
+
373
+ def session(payload: dict) -> dict | None:
374
+ cwd = payload.get("cwd")
375
+ folder = Path(cwd) if isinstance(cwd, str) and os.path.isabs(cwd) else Path.cwd()
376
+ # A session folder that is missing or cannot be listed is an error, so nothing is said.
377
+ with os.scandir(folder):
378
+ pass
379
+ goals = active_goals(project_root(folder))
380
+ if not goals:
381
+ return None
382
+ lines = []
383
+ for path, status in goals:
384
+ name = shown_path(path, folder)
385
+ if name is None or len(lines) == MAX_GOALS_SHOWN:
386
+ continue
387
+ lines.append(f"- {name} (STATUS: {status})" if status in KNOWN_STATUSES else f"- {name} (STATUS not shown)")
388
+ if len(goals) > len(lines):
389
+ lines.append(f"- {len(goals) - len(lines)} more active goal file(s) not listed")
390
+ context = "\n".join((SESSION_INTRO, *lines))
391
+ return {"hookSpecificOutput": {"hookEventName": "SessionStart", "additionalContext": context}}
392
+
393
+
232
394
  def main(argv: list[str] | None = None) -> int:
233
395
  argv = sys.argv[1:] if argv is None else argv
234
- if len(argv) != 1 or argv[0] not in ("pre-tool", "stop"):
235
- print("usage: flow_guard.py pre-tool|stop (reads the hook payload on stdin)", file=sys.stderr)
396
+ if len(argv) != 1 or argv[0] not in ("pre-tool", "stop", "session"):
397
+ print("usage: flow_guard.py pre-tool|stop|session (reads the hook payload on stdin)", file=sys.stderr)
236
398
  return 1
399
+ if argv[0] == "session":
400
+ # Whatever goes wrong, a session must start with no guard text and no error notice.
401
+ try:
402
+ payload = json.loads(sys.stdin.read())
403
+ result = session(payload) if isinstance(payload, dict) else None
404
+ if result is not None:
405
+ sys.stdout.write(json.dumps(result) + "\n")
406
+ sys.stdout.flush()
407
+ except Exception:
408
+ # Output that could not be written must not fail again when Python exits.
409
+ try:
410
+ os.dup2(os.open(os.devnull, os.O_WRONLY), sys.stdout.fileno())
411
+ except (OSError, ValueError):
412
+ pass
413
+ return 0
237
414
  try:
238
415
  payload = json.loads(sys.stdin.read())
239
416
  except ValueError as exc: