nobrainer-tech-flow 2.5.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 (36) 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 +78 -36
  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 +51 -30
  11. package/docs/SKILL_CURATION.md +6 -6
  12. package/docs/releases/v2.6.0.md +73 -0
  13. package/docs/releases/v2.7.0.md +145 -0
  14. package/gemini-extension.json +1 -1
  15. package/package.json +1 -1
  16. package/plugin.json +1 -1
  17. package/scripts/install.py +3 -1
  18. package/scripts/install.sh +25 -6
  19. package/scripts/install_guards.py +29 -13
  20. package/scripts/install_personalization.py +52 -17
  21. package/skills/nobrainer-auto-fine-tune/SKILL.md +1 -1
  22. package/skills/nobrainer-build/SKILL.md +17 -2
  23. package/skills/nobrainer-security/SKILL.md +10 -0
  24. package/skills/nobrainer-team/SKILL.md +43 -64
  25. package/skills/nobrainer-team/references/team-plan.md +7 -6
  26. package/skills/nobrainer-tech-flow/SKILL.md +13 -13
  27. package/skills/nobrainer-tech-flow/VERSION +1 -1
  28. package/skills/nobrainer-tech-flow/references/correction-hooks.md +11 -5
  29. package/skills/nobrainer-tech-flow/references/daily-update.md +5 -2
  30. package/skills/nobrainer-tech-flow/references/delivery.md +50 -20
  31. package/skills/nobrainer-tech-flow/references/long-run-state.md +31 -5
  32. package/skills/nobrainer-tech-flow/references/model-routing.md +13 -11
  33. package/skills/nobrainer-tech-flow/references/project-start.md +2 -2
  34. package/skills/nobrainer-tech-flow/references/routing.md +6 -8
  35. package/skills/nobrainer-tech-flow/references/setup.md +6 -7
  36. package/skills/nobrainer-tech-flow/scripts/flow_guard.py +179 -2
@@ -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: