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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/AGENTS.md +8 -8
- package/CLAUDE.md +8 -8
- package/README.md +78 -36
- package/adapters/bootstrap.md +2 -2
- package/assets/nobrainer-workflow.svg +2 -2
- package/docs/CHAT_PLUGINS.md +4 -2
- package/docs/INSTALL.md +51 -30
- package/docs/SKILL_CURATION.md +6 -6
- package/docs/releases/v2.6.0.md +73 -0
- package/docs/releases/v2.7.0.md +145 -0
- package/gemini-extension.json +1 -1
- package/package.json +1 -1
- package/plugin.json +1 -1
- package/scripts/install.py +3 -1
- package/scripts/install.sh +25 -6
- package/scripts/install_guards.py +29 -13
- package/scripts/install_personalization.py +52 -17
- package/skills/nobrainer-auto-fine-tune/SKILL.md +1 -1
- package/skills/nobrainer-build/SKILL.md +17 -2
- package/skills/nobrainer-security/SKILL.md +10 -0
- package/skills/nobrainer-team/SKILL.md +43 -64
- package/skills/nobrainer-team/references/team-plan.md +7 -6
- package/skills/nobrainer-tech-flow/SKILL.md +13 -13
- package/skills/nobrainer-tech-flow/VERSION +1 -1
- package/skills/nobrainer-tech-flow/references/correction-hooks.md +11 -5
- package/skills/nobrainer-tech-flow/references/daily-update.md +5 -2
- package/skills/nobrainer-tech-flow/references/delivery.md +50 -20
- package/skills/nobrainer-tech-flow/references/long-run-state.md +31 -5
- package/skills/nobrainer-tech-flow/references/model-routing.md +13 -11
- package/skills/nobrainer-tech-flow/references/project-start.md +2 -2
- package/skills/nobrainer-tech-flow/references/routing.md +6 -8
- package/skills/nobrainer-tech-flow/references/setup.md +6 -7
- 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:
|