nobrainer-tech-flow 0.0.0-stage → 2.4.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 (149) hide show
  1. package/.agents/plugins/marketplace.json +20 -0
  2. package/.claude-plugin/marketplace.json +18 -0
  3. package/.claude-plugin/plugin.json +21 -0
  4. package/.codex-plugin/plugin.json +47 -0
  5. package/.opencode/INSTALL.md +37 -0
  6. package/.opencode/plugins/nobrainer-tech-skills.js +66 -0
  7. package/.pi/extensions/nobrainer-tech-skills.js +64 -0
  8. package/AGENTS.md +204 -0
  9. package/CLAUDE.md +204 -0
  10. package/CONTRIBUTING.md +70 -0
  11. package/GEMINI.md +1 -0
  12. package/LICENSE +21 -0
  13. package/README.md +658 -2
  14. package/SECURITY.md +33 -0
  15. package/adapters/bootstrap.md +27 -0
  16. package/assets/nobrainer-tech-logo.svg +1 -0
  17. package/assets/nobrainer-workflow.svg +155 -0
  18. package/assets/plugin-logo.png +0 -0
  19. package/bin/nobrainer-tech-flow.mjs +48 -0
  20. package/docs/BOUNDED_RUNNER.md +60 -0
  21. package/docs/CHAT_PLUGINS.md +29 -0
  22. package/docs/COMPATIBILITY.md +371 -0
  23. package/docs/INSTALL.md +626 -0
  24. package/docs/MIGRATION_TO_FLOW.md +117 -0
  25. package/docs/NAMING.md +39 -0
  26. package/docs/PRIVACY.md +9 -0
  27. package/docs/SESSION_RESTART.md +70 -0
  28. package/docs/SKILL_CURATION.md +124 -0
  29. package/docs/TESTING.md +232 -0
  30. package/docs/TRY_IT.md +109 -0
  31. package/docs/releases/v1.0.0.md +81 -0
  32. package/docs/releases/v1.1.0.md +85 -0
  33. package/docs/releases/v1.10.0.md +34 -0
  34. package/docs/releases/v1.11.0-publication-readback.md +25 -0
  35. package/docs/releases/v1.11.0.md +42 -0
  36. package/docs/releases/v1.12.0.md +30 -0
  37. package/docs/releases/v1.13.0.md +46 -0
  38. package/docs/releases/v1.14.0.md +43 -0
  39. package/docs/releases/v1.14.1.md +21 -0
  40. package/docs/releases/v1.2.0.md +87 -0
  41. package/docs/releases/v1.2.1.md +92 -0
  42. package/docs/releases/v1.3.0-publication-readback.md +69 -0
  43. package/docs/releases/v1.3.0.md +66 -0
  44. package/docs/releases/v1.3.1-publication-readback.md +76 -0
  45. package/docs/releases/v1.3.1.md +59 -0
  46. package/docs/releases/v1.4.0-publication-readback.md +81 -0
  47. package/docs/releases/v1.4.0.md +43 -0
  48. package/docs/releases/v1.5.0-publication-readback.md +76 -0
  49. package/docs/releases/v1.5.0.md +54 -0
  50. package/docs/releases/v1.6.0.md +64 -0
  51. package/docs/releases/v1.6.1-publication-readback.md +53 -0
  52. package/docs/releases/v1.6.1.md +82 -0
  53. package/docs/releases/v1.7.0.md +42 -0
  54. package/docs/releases/v1.7.1-publication-readback.md +64 -0
  55. package/docs/releases/v1.7.1.md +49 -0
  56. package/docs/releases/v1.8.0.md +63 -0
  57. package/docs/releases/v1.8.1.md +32 -0
  58. package/docs/releases/v1.9.0.md +33 -0
  59. package/docs/releases/v2.0.0-candidate.md +28 -0
  60. package/docs/releases/v2.0.0.md +53 -0
  61. package/docs/releases/v2.0.1.md +135 -0
  62. package/docs/releases/v2.0.2.md +189 -0
  63. package/docs/releases/v2.1.0.md +64 -0
  64. package/docs/releases/v2.1.1.md +43 -0
  65. package/docs/releases/v2.2.0.md +64 -0
  66. package/docs/releases/v2.2.1.md +11 -0
  67. package/docs/releases/v2.3.0.md +97 -0
  68. package/docs/releases/v2.3.1.md +71 -0
  69. package/docs/releases/v2.3.2.md +242 -0
  70. package/docs/releases/v2.4.0.md +126 -0
  71. package/gemini-extension.json +6 -0
  72. package/hooks/claude-hooks.json +17 -0
  73. package/hooks/hooks-cursor.json +10 -0
  74. package/hooks/run-hook.cmd +36 -0
  75. package/hooks/session-start +38 -0
  76. package/package.json +69 -4
  77. package/plugin.json +51 -0
  78. package/scripts/build_plugin.py +50 -0
  79. package/scripts/check_flow_update.py +18 -0
  80. package/scripts/check_public_commit.py +86 -0
  81. package/scripts/fetch_public_refs.py +69 -0
  82. package/scripts/install.py +600 -0
  83. package/scripts/install.sh +1392 -0
  84. package/scripts/install_guards.py +279 -0
  85. package/scripts/install_personalization.py +527 -0
  86. package/scripts/install_skills.py +951 -0
  87. package/scripts/npm_install.py +85 -0
  88. package/scripts/recommend_flow_setup.py +1522 -0
  89. package/scripts/validate_skills.py +465 -0
  90. package/skills/nobrainer-auto-fine-tune/SKILL.md +79 -0
  91. package/skills/nobrainer-auto-fine-tune/references/capacity-audit.md +49 -0
  92. package/skills/nobrainer-autoimprove/SKILL.md +212 -0
  93. package/skills/nobrainer-autoimprove/scripts/count_words.py +22 -0
  94. package/skills/nobrainer-browser/SKILL.md +234 -0
  95. package/skills/nobrainer-browser/references/cdp-profile-restart.md +93 -0
  96. package/skills/nobrainer-build/SKILL.md +173 -0
  97. package/skills/nobrainer-codex-context/SKILL.md +111 -0
  98. package/skills/nobrainer-codex-context/scripts/codex_context.py +175 -0
  99. package/skills/nobrainer-decide/SKILL.md +198 -0
  100. package/skills/nobrainer-dispatcher/SKILL.md +256 -0
  101. package/skills/nobrainer-dispatcher/agents/openai.yaml +4 -0
  102. package/skills/nobrainer-rca/SKILL.md +163 -0
  103. package/skills/nobrainer-research/SKILL.md +131 -0
  104. package/skills/nobrainer-review/SKILL.md +195 -0
  105. package/skills/nobrainer-security/SKILL.md +166 -0
  106. package/skills/nobrainer-sessions/SKILL.md +223 -0
  107. package/skills/nobrainer-sessions/references/evidence-receipt.md +55 -0
  108. package/skills/nobrainer-sessions/references/protocol.md +337 -0
  109. package/skills/nobrainer-sessions/references/session-restart.md +255 -0
  110. package/skills/nobrainer-sessions/scripts/evidence_receipt.py +373 -0
  111. package/skills/nobrainer-sessions/scripts/restart_gate.py +140 -0
  112. package/skills/nobrainer-sessions/scripts/run_bounded.py +390 -0
  113. package/skills/nobrainer-sessions/scripts/session_title.py +43 -0
  114. package/skills/nobrainer-skill-doctor/SKILL.md +149 -0
  115. package/skills/nobrainer-skill-doctor/references/astra.md +19 -0
  116. package/skills/nobrainer-skill-doctor/scripts/audit.py +100 -0
  117. package/skills/nobrainer-skill-doctor/scripts/context_candidates.py +133 -0
  118. package/skills/nobrainer-spec-driven-development/SKILL.md +132 -0
  119. package/skills/nobrainer-spec-driven-development/references/spec-template.md +114 -0
  120. package/skills/nobrainer-team/SKILL.md +165 -0
  121. package/skills/nobrainer-team/references/team-plan.md +64 -0
  122. package/skills/nobrainer-tech-flow/SKILL.md +239 -0
  123. package/skills/nobrainer-tech-flow/VERSION +1 -0
  124. package/skills/nobrainer-tech-flow/references/autopilot.md +72 -0
  125. package/skills/nobrainer-tech-flow/references/codex-routing.md +21 -0
  126. package/skills/nobrainer-tech-flow/references/communication.md +108 -0
  127. package/skills/nobrainer-tech-flow/references/correction-hooks.md +110 -0
  128. package/skills/nobrainer-tech-flow/references/daily-update.md +28 -0
  129. package/skills/nobrainer-tech-flow/references/delivery.md +243 -0
  130. package/skills/nobrainer-tech-flow/references/idea-to-delivery.md +82 -0
  131. package/skills/nobrainer-tech-flow/references/long-run-state.md +209 -0
  132. package/skills/nobrainer-tech-flow/references/model-routing.md +83 -0
  133. package/skills/nobrainer-tech-flow/references/optional-decisions.md +83 -0
  134. package/skills/nobrainer-tech-flow/references/project-start.md +22 -0
  135. package/skills/nobrainer-tech-flow/references/routing.md +125 -0
  136. package/skills/nobrainer-tech-flow/references/setup.md +180 -0
  137. package/skills/nobrainer-tech-flow/references/worktrees.md +138 -0
  138. package/skills/nobrainer-tech-flow/scripts/check_flow_update.py +298 -0
  139. package/skills/nobrainer-tech-flow/scripts/cleanup_worktree.py +327 -0
  140. package/skills/nobrainer-tech-flow/scripts/decision_config.py +107 -0
  141. package/skills/nobrainer-tech-flow/scripts/flow_guard.py +252 -0
  142. package/skills/nobrainer-tech-flow/scripts/typed_decisions.py +210 -0
  143. package/skills/nobrainer-wiki/SKILL.md +164 -0
  144. package/skills/nobrainer-wiki/references/setup.md +88 -0
  145. package/skills/nobrainer-writing/SKILL.md +272 -0
  146. package/skills/nobrainer-writing/agents/openai.yaml +4 -0
  147. package/skills/nobrainer-writing/references/brief-artifacts.md +357 -0
  148. package/skills/nobrainer-writing/references/plain-technical-english.md +76 -0
  149. package/skills/nobrainer-writing/references/research.md +86 -0
@@ -0,0 +1,107 @@
1
+ #!/usr/bin/env python3
2
+ """Inspect or explicitly set non-secret optional decision preferences."""
3
+ import argparse
4
+ import json
5
+ import os
6
+ from pathlib import Path
7
+ import tempfile
8
+
9
+
10
+ DEFAULT = {
11
+ "schema_version": 1,
12
+ "provider": "core",
13
+ "mode": "off",
14
+ "model": None,
15
+ "timeout_seconds": 10,
16
+ "max_calls_per_run": 0,
17
+ }
18
+ MODELS = {"core": None, "jev": "jev-latest", "laya": "aac6fef/laya-multilingual-mlx"}
19
+
20
+
21
+ def validate(value):
22
+ if not isinstance(value, dict) or set(value) != set(DEFAULT):
23
+ raise ValueError("Configuration fields do not match schema version 1")
24
+ if type(value["schema_version"]) is not int or value["schema_version"] != 1:
25
+ raise ValueError("Unsupported schema version")
26
+ if not isinstance(value["provider"], str) or value["provider"] not in MODELS or value["mode"] not in ("off", "shadow"):
27
+ raise ValueError("Unknown provider or mode")
28
+ if value["provider"] == "core":
29
+ if value["mode"] != "off" or value["model"] is not None or value["max_calls_per_run"] != 0:
30
+ raise ValueError("Core profile requires off mode, no model and zero calls")
31
+ elif not isinstance(value["model"], str) or not value["model"].strip():
32
+ raise ValueError("An optional provider requires a model identifier")
33
+ for field, low, high in (("timeout_seconds", 1, 120), ("max_calls_per_run", 0, 100)):
34
+ if type(value[field]) is not int or not low <= value[field] <= high:
35
+ raise ValueError(f"Invalid {field}")
36
+ if value["mode"] == "shadow" and value["max_calls_per_run"] == 0:
37
+ raise ValueError("Shadow mode requires a positive call limit")
38
+ return value
39
+
40
+
41
+ def read(path):
42
+ if not path.exists():
43
+ return {"status": "DEFAULT_CORE", "configured": False, "config": dict(DEFAULT)}
44
+ try:
45
+ value = validate(json.loads(path.read_text(encoding="utf-8")))
46
+ return {"status": "CONFIGURED", "configured": True, "config": value}
47
+ except (OSError, ValueError, TypeError):
48
+ return {"status": "INVALID_CONFIG_CORE_FALLBACK", "configured": True, "config": dict(DEFAULT)}
49
+
50
+
51
+ def configure(path, provider, mode, model, timeout, calls):
52
+ value = validate({"schema_version": 1, "provider": provider, "mode": mode,
53
+ "model": model, "timeout_seconds": timeout, "max_calls_per_run": calls})
54
+ if path.is_symlink():
55
+ raise ValueError("Refusing to replace a symlink configuration")
56
+ path.parent.mkdir(parents=True, exist_ok=True)
57
+ fd, temporary = tempfile.mkstemp(dir=path.parent, prefix=".flow-config-")
58
+ try:
59
+ with os.fdopen(fd, "w", encoding="utf-8") as stream:
60
+ json.dump(value, stream, indent=2)
61
+ stream.write("\n")
62
+ os.replace(temporary, path)
63
+ finally:
64
+ if os.path.exists(temporary):
65
+ os.unlink(temporary)
66
+ return read(path)
67
+
68
+
69
+ def main():
70
+ parser = argparse.ArgumentParser(description=__doc__)
71
+ parser.add_argument("--config", type=Path, default=Path(".nobrainer/flow.json"))
72
+ sub = parser.add_subparsers(dest="command", required=True)
73
+ sub.add_parser("show")
74
+ setup = sub.add_parser("setup")
75
+ setup.add_argument("--non-interactive", action="store_true")
76
+ setter = sub.add_parser("set")
77
+ setter.add_argument("--provider", choices=tuple(MODELS), required=True)
78
+ setter.add_argument("--mode", choices=("off", "shadow"), default="off")
79
+ setter.add_argument("--model")
80
+ setter.add_argument("--timeout", type=int, default=10)
81
+ setter.add_argument("--max-calls", type=int, default=0)
82
+ args = parser.parse_args()
83
+ try:
84
+ if args.command == "setup":
85
+ result = read(args.config)
86
+ if not result["configured"]:
87
+ import sys
88
+ choice = "core"
89
+ if not args.non_interactive and sys.stdin.isatty():
90
+ choice = input("Optional decisions: enter core (default), jev (remote), or laya (local): ").strip().lower() or "core"
91
+ if choice not in MODELS:
92
+ raise ValueError("Unknown choice")
93
+ # Remember provider choice without authorizing data or charges.
94
+ result = configure(args.config, choice, "off", MODELS[choice], 10, 0)
95
+ else:
96
+ result = read(args.config) if args.command == "show" else configure(
97
+ args.config, args.provider, args.mode, args.model or MODELS[args.provider],
98
+ args.timeout, args.max_calls)
99
+ except (ValueError, OSError):
100
+ print(json.dumps({"status": "CONFIG_NOT_SAVED"}))
101
+ return 2
102
+ print(json.dumps(result, indent=2))
103
+ return 0
104
+
105
+
106
+ if __name__ == "__main__":
107
+ raise SystemExit(main())
@@ -0,0 +1,252 @@
1
+ #!/usr/bin/env python3
2
+ """Optional Claude Code guards for nobrainer-tech-flow.
3
+
4
+ ``pre-tool`` reads a PreToolUse payload and asks the person before a common risky
5
+ shell command; it never denies. ``stop`` reads a Stop or SubagentStop payload and
6
+ blocks the stop once when a turn that edited files or ran commands ends without a
7
+ proof line. Both fail open: input they cannot read is allowed, with a note on stderr.
8
+
9
+ This is a seatbelt, not a security boundary. It reads the command text, so a
10
+ command hidden behind ``sh -c``, a variable or an alias can pass unnoticed, and a
11
+ risky phrase inside a quoted string can ask without need.
12
+
13
+ Install with ``scripts/install_guards.py``; the field names follow
14
+ https://code.claude.com/docs/en/hooks.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import os
21
+ import re
22
+ import shlex
23
+ import sys
24
+ import tempfile
25
+ from pathlib import Path
26
+
27
+
28
+ # One shell command: the text up to the next separator.
29
+ ARGS = r"[^;&|\n]*?"
30
+ # Global options between a tool and its subcommand, each "-x", "--name=value" or an
31
+ # option and one value word, as in "npm -w pkg publish" or "gh -R owner/repo release".
32
+ # Narrower than ARGS, so "npm run publish" or "gh issue create --title release" pass.
33
+ OPTIONS = r"(?:\s+-[^\s;&|]+(?:\s+[^\s;&|-][^\s;&|]*)?)*"
34
+ END = r"(?=\s|$|[;&|)])"
35
+ RISKY = (
36
+ # --force-with-lease and a "+branch" refspec ask too: they still rewrite the remote.
37
+ (re.compile(rf"\bgit\b{ARGS}\spush\b{ARGS}\s(?:--force\S*|-[a-zA-Z]*f[a-zA-Z]*|\+[A-Za-z]\S*){END}"),
38
+ "git push --force rewrites the remote branch"),
39
+ (re.compile(rf"\bgit\b{ARGS}\sreset\b{ARGS}\s--hard\b"), "git reset --hard discards uncommitted work"),
40
+ (re.compile(rf"\bgit\b{ARGS}\sclean\b{ARGS}\s(?:--force\b|-[a-zA-Z]*f)"),
41
+ "git clean -f deletes untracked files"),
42
+ (re.compile(rf"\bvercel\b{ARGS}\s--prod\b"), "vercel --prod deploys to production"),
43
+ (re.compile(rf"\bnpm\b{OPTIONS}\s+publish{END}"), "npm publish releases a package"),
44
+ (re.compile(rf"\bterraform\b{ARGS}\s(?:apply|destroy)\b"), "terraform apply or destroy changes infrastructure"),
45
+ (re.compile(rf"\bkubectl\b{ARGS}\sdelete\b"), "kubectl delete removes cluster resources"),
46
+ (re.compile(rf"\bgh\b{OPTIONS}\s+release\s+create{END}"), "gh release create publishes a release"),
47
+ )
48
+ # An rm, also path-qualified as /bin/rm or quoted as "rm", that is not part of "git rm";
49
+ # its arguments run to the next separator.
50
+ RM = re.compile(r"(?<![\w./-])[\"']?(?:[\w.-]*/)*rm[\"']?(?=\s)([^;&|\n)]*)")
51
+ # Commands are matched one segment at a time. A segment this long that names a risky
52
+ # tool is asked about whole, because the patterns slow down sharply on long lines.
53
+ SEPARATOR = re.compile(r"[;&|\n]")
54
+ LONGEST_SEGMENT = 2000
55
+ RISKY_TOOL = re.compile(r"\b(?:git|vercel|npm|terraform|kubectl|gh)\b")
56
+ # A directory change before an rm means the hook's cwd no longer says where a relative
57
+ # target is, so the temporary-folder exemption cannot apply to it.
58
+ CHANGE_DIRECTORY = re.compile(r"(?<![\w./-])(?:cd|pushd|popd)(?=\s|$|[;&|)])")
59
+ RECURSIVE = re.compile(r"^(?:-[a-zA-Z]*[rR][a-zA-Z]*|--recursive)$")
60
+ TEMP_ROOTS = ("/tmp", "/private/tmp", "/var/folders", "/private/var/folders")
61
+ TMPDIR_REFERENCE = re.compile(r"^(?:\$TMPDIR|\$\{TMPDIR\})(?=/|$)")
62
+ EDITING_TOOLS = {"Edit", "MultiEdit", "Write", "NotebookEdit", "Bash"}
63
+ # A proof line may sit in a bulleted or numbered list item, with the label in bold.
64
+ PROOF_LINE = re.compile(r"^[\s>*_\-]*(?:\d+[.)]\s*)?(?:\*\*|__)?(?:Proof|Checked)(?:\*\*|__)?:", re.MULTILINE)
65
+ STOP_REASON = (
66
+ "Flow guard: this turn changed files or ran commands, but the final message has no "
67
+ "proof. Add a line starting with 'Proof:' or 'Checked:' that names the command, test "
68
+ "or readback showing the result, or say UNVERIFIED and what is missing."
69
+ )
70
+
71
+
72
+ def note(message: str) -> None:
73
+ print(f"flow_guard: {message}", file=sys.stderr)
74
+
75
+
76
+ def temp_roots() -> list[str]:
77
+ roots = list(TEMP_ROOTS)
78
+ for value in (os.environ.get("TMPDIR"), tempfile.gettempdir()):
79
+ if value and os.path.isabs(value):
80
+ roots.append(os.path.normpath(value))
81
+ # Compared with real paths below, so each root counts in its real form as well
82
+ # (/tmp is /private/tmp on macOS).
83
+ return sorted({form for root in roots for form in (root, os.path.realpath(root))})
84
+
85
+
86
+ def inside_temp(argument: str, cwd: str | None, roots: list[str]) -> bool:
87
+ """Whether ARGUMENT names a path strictly inside a temporary folder."""
88
+
89
+ tmpdir = os.environ.get("TMPDIR") or tempfile.gettempdir()
90
+ argument = TMPDIR_REFERENCE.sub(lambda _: tmpdir, argument)
91
+ if "$" in argument or "`" in argument or argument.startswith("~"):
92
+ return False
93
+ if not os.path.isabs(argument):
94
+ if not cwd or not os.path.isabs(cwd):
95
+ return False
96
+ argument = os.path.join(cwd, argument)
97
+ # Links on the way are followed before any "..", as the shell does: a folder under
98
+ # /tmp that links to a project is the project, and its real place decides.
99
+ path = os.path.realpath(argument)
100
+ return any(path.startswith(root.rstrip("/") + "/") for root in roots)
101
+
102
+
103
+ def risky_rm(command: str, cwd: str | None) -> str | None:
104
+ roots = temp_roots()
105
+ for match in RM.finditer(command):
106
+ before = SEPARATOR.split(command[: match.start()])[-1].split()
107
+ if before and os.path.basename(before[-1]) == "git":
108
+ continue
109
+ base = None if CHANGE_DIRECTORY.search(command, 0, match.start()) else cwd
110
+ try:
111
+ words = shlex.split(match.group(1))
112
+ except ValueError:
113
+ # An open quote means a separator inside it cut the arguments short, or the
114
+ # "rm" is only a word in quoted text. Only a recursive rm is asked about.
115
+ rest = command[match.start(1):].split("\n")[0].split()
116
+ if any(RECURSIVE.match(word.strip("\"'")) for word in rest):
117
+ return "rm -r with arguments the guard cannot read deletes files for good"
118
+ continue
119
+ if not any(RECURSIVE.match(word) for word in words):
120
+ continue
121
+ paths = [word for word in words if not word.startswith("-")]
122
+ if not paths or not all(inside_temp(path, base, roots) for path in paths):
123
+ return "rm -r outside a temporary folder deletes files for good"
124
+ return None
125
+
126
+
127
+ def pre_tool(payload: dict) -> dict | None:
128
+ if payload.get("tool_name") != "Bash":
129
+ return None
130
+ tool_input = payload.get("tool_input")
131
+ command = tool_input.get("command") if isinstance(tool_input, dict) else None
132
+ if not isinstance(command, str):
133
+ return None
134
+ cwd = payload.get("cwd") if isinstance(payload.get("cwd"), str) else None
135
+ # A backslash line continuation is one command, so join it before splitting.
136
+ command = command.replace("\\\n", " ")
137
+ reasons = []
138
+ for segment in SEPARATOR.split(command):
139
+ if len(segment) > LONGEST_SEGMENT and RISKY_TOOL.search(segment):
140
+ reasons.append("a long command names a risky tool and is too long to check")
141
+ continue
142
+ reasons += [reason for pattern, reason in RISKY if pattern.search(segment) and reason not in reasons]
143
+ rm = risky_rm(command, cwd)
144
+ if rm:
145
+ reasons.insert(0, rm)
146
+ if not reasons:
147
+ return None
148
+ return {
149
+ "hookSpecificOutput": {
150
+ "hookEventName": "PreToolUse",
151
+ "permissionDecision": "ask",
152
+ "permissionDecisionReason": (
153
+ f"Flow guard: {'; '.join(reasons)}. Confirm only if this is intended "
154
+ "(a seatbelt check, not a security boundary)."
155
+ ),
156
+ }
157
+ }
158
+
159
+
160
+ def blocks(entry: dict) -> list:
161
+ message = entry.get("message")
162
+ content = message.get("content") if isinstance(message, dict) else None
163
+ if isinstance(content, str):
164
+ return [{"type": "text", "text": content}]
165
+ return [block for block in content if isinstance(block, dict)] if isinstance(content, list) else []
166
+
167
+
168
+ def read_turn(path: Path, skip_sidechain: bool) -> tuple[set[str], str]:
169
+ """The tools the last turn used and its final assistant text.
170
+
171
+ A turn starts at the last user entry that is a prompt rather than tool results.
172
+ Lines that are not JSON (a line still being written) are skipped.
173
+ """
174
+
175
+ entries = []
176
+ with path.open(encoding="utf-8", errors="replace") as handle:
177
+ for line in handle:
178
+ try:
179
+ entry = json.loads(line)
180
+ except ValueError:
181
+ continue
182
+ if not isinstance(entry, dict) or entry.get("type") not in ("user", "assistant"):
183
+ continue
184
+ if skip_sidechain and entry.get("isSidechain"):
185
+ continue
186
+ entries.append(entry)
187
+ if not entries:
188
+ raise ValueError(f"no transcript entries in {path}")
189
+ start = 0
190
+ for index, entry in enumerate(entries):
191
+ if entry["type"] == "user" and not any(block.get("type") == "tool_result" for block in blocks(entry)):
192
+ start = index
193
+ tools: set[str] = set()
194
+ final = ""
195
+ for entry in entries[start:]:
196
+ if entry["type"] != "assistant":
197
+ continue
198
+ for block in blocks(entry):
199
+ if block.get("type") == "tool_use" and isinstance(block.get("name"), str):
200
+ tools.add(block["name"])
201
+ text = "\n".join(
202
+ block["text"] for block in blocks(entry) if block.get("type") == "text" and isinstance(block.get("text"), str)
203
+ )
204
+ if text.strip():
205
+ final = text
206
+ return tools, final
207
+
208
+
209
+ def stop(payload: dict) -> dict | None:
210
+ if payload.get("stop_hook_active") is True:
211
+ return None
212
+ event = payload.get("hook_event_name")
213
+ transcript = payload.get("agent_transcript_path") if event == "SubagentStop" else None
214
+ transcript = transcript or payload.get("transcript_path")
215
+ if not isinstance(transcript, str) or not transcript:
216
+ note("no transcript path in the payload; allowing the stop")
217
+ return None
218
+ try:
219
+ tools, final = read_turn(Path(transcript).expanduser(), skip_sidechain=event != "SubagentStop")
220
+ except (OSError, ValueError) as exc:
221
+ note(f"cannot read the transcript ({exc}); allowing the stop")
222
+ return None
223
+ if not tools & EDITING_TOOLS:
224
+ return None
225
+ message = payload.get("last_assistant_message")
226
+ text = message if isinstance(message, str) else final
227
+ if PROOF_LINE.search(text) or "UNVERIFIED" in text:
228
+ return None
229
+ return {"decision": "block", "reason": STOP_REASON}
230
+
231
+
232
+ def main(argv: list[str] | None = None) -> int:
233
+ 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)
236
+ return 1
237
+ try:
238
+ payload = json.loads(sys.stdin.read())
239
+ except ValueError as exc:
240
+ note(f"cannot parse the hook input ({exc}); allowing")
241
+ return 0
242
+ if not isinstance(payload, dict):
243
+ note("the hook input is not a JSON object; allowing")
244
+ return 0
245
+ result = pre_tool(payload) if argv[0] == "pre-tool" else stop(payload)
246
+ if result is not None:
247
+ print(json.dumps(result))
248
+ return 0
249
+
250
+
251
+ if __name__ == "__main__":
252
+ raise SystemExit(main())
@@ -0,0 +1,210 @@
1
+ #!/usr/bin/env python3
2
+ """Bounded advisory typed decisions; never executes selected actions."""
3
+ import argparse
4
+ import json
5
+ import math
6
+ import os
7
+ import platform
8
+ from pathlib import Path
9
+ import subprocess
10
+ import sys
11
+ import time
12
+ import urllib.request
13
+
14
+ from decision_config import read
15
+
16
+
17
+ def finite(value, low, high):
18
+ return type(value) in (int, float) and math.isfinite(value) and low <= value <= high
19
+
20
+
21
+ def validate_request(request):
22
+ if not isinstance(request, dict) or set(request) != {"state", "questions"}:
23
+ raise ValueError("request_fields")
24
+ if not isinstance(request["state"], (str, dict, list)):
25
+ raise ValueError("state")
26
+ questions = request["questions"]
27
+ if not isinstance(questions, dict) or not 1 <= len(questions) <= 16:
28
+ raise ValueError("questions")
29
+ for key, question in questions.items():
30
+ if not isinstance(key, str) or not key or not isinstance(question, dict):
31
+ raise ValueError("question")
32
+ if not isinstance(question.get("instructions"), (str, dict, list)) or not question["instructions"]:
33
+ raise ValueError("instructions")
34
+ kind = question.get("type")
35
+ criteria = question.get("criteria")
36
+ if kind == "choice":
37
+ if not isinstance(criteria, dict) or not criteria or not all(isinstance(k, str) and k for k in criteria):
38
+ raise ValueError("choice_criteria")
39
+ elif kind == "score":
40
+ if not isinstance(criteria, list) or len(criteria) < 2:
41
+ raise ValueError("score_criteria")
42
+ elif kind != "noul":
43
+ raise ValueError("question_type")
44
+ return request
45
+
46
+
47
+ def validate_answers(request, response):
48
+ answers = response.get("answers") if isinstance(response, dict) else None
49
+ if not isinstance(answers, dict) or set(answers) != set(request["questions"]):
50
+ raise ValueError("answer_ids")
51
+ clean = {}
52
+ for key, question in request["questions"].items():
53
+ answer = answers[key]
54
+ kind = question["type"]
55
+ if not isinstance(answer, dict) or answer.get("type") != kind:
56
+ raise ValueError("answer_type")
57
+ if kind == "noul":
58
+ if not finite(answer.get("noul"), 0, 1):
59
+ raise ValueError("noul")
60
+ clean[key] = {"type": kind, "noul": answer["noul"]}
61
+ continue
62
+ keys = set(question["criteria"]) if kind == "choice" else {str(i) for i in range(len(question["criteria"]))}
63
+ probabilities = answer.get("probabilities")
64
+ if not isinstance(probabilities, dict) or set(probabilities) != keys:
65
+ raise ValueError("distribution_keys")
66
+ if not all(finite(p, 0, 1) for p in probabilities.values()) or abs(sum(probabilities.values()) - 1) > 0.02:
67
+ raise ValueError("distribution_values")
68
+ if not finite(answer.get("confidence"), 0, 1):
69
+ raise ValueError("confidence")
70
+ value = answer.get(kind)
71
+ if kind == "choice" and (not isinstance(value, str) or value not in keys):
72
+ raise ValueError("choice")
73
+ if kind == "score" and not finite(value, 0, len(keys) - 1):
74
+ raise ValueError("score")
75
+ clean[key] = {"type": kind, kind: value, "confidence": answer["confidence"], "probabilities": probabilities}
76
+ return clean
77
+
78
+
79
+ class NoRedirect(urllib.request.HTTPRedirectHandler):
80
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
81
+ raise ValueError("redirect_not_allowed")
82
+
83
+
84
+ def worker(provider, model, request):
85
+ if provider == "jev":
86
+ key = os.environ.get("TYPESAFE_API_KEY")
87
+ if not key:
88
+ return {"error": "MISSING_TYPESAFE_API_KEY"}
89
+ payload = json.dumps(dict(request, model=model)).encode()
90
+ req = urllib.request.Request("https://api.typesafe.ai/v1/systemone", data=payload,
91
+ headers={"Authorization": "Bearer " + key, "Content-Type": "application/json"})
92
+ with urllib.request.build_opener(NoRedirect).open(req, timeout=120) as response:
93
+ raw = response.read(1_000_001)
94
+ if len(raw) > 1_000_000:
95
+ return {"error": "RESPONSE_TOO_LARGE"}
96
+ return json.loads(raw)
97
+ if provider != "laya":
98
+ return {"error": "UNKNOWN_PROVIDER"}
99
+ if platform.system() != "Darwin" or platform.machine() != "arm64":
100
+ return {"error": "UNSUPPORTED_LOCAL_RUNTIME"}
101
+ # Never fetch weights implicitly: model must already be available offline.
102
+ os.environ["HF_HUB_OFFLINE"] = "1"
103
+ os.environ["TRANSFORMERS_OFFLINE"] = "1"
104
+ try:
105
+ import laya_mlx
106
+ from huggingface_hub import snapshot_download
107
+ except ImportError:
108
+ return {"error": "MISSING_LOCAL_RUNTIME"}
109
+ if not Path(model).is_dir():
110
+ try:
111
+ model_path = snapshot_download(model, local_files_only=True, allow_patterns=[
112
+ "model.safetensors", "rl_agent_config.json", "encoder/config.json", "tokenizer/*", "mlx_config.json"])
113
+ except Exception:
114
+ return {"error": "MISSING_LOCAL_MODEL"}
115
+ else:
116
+ model_path = model
117
+ try:
118
+ agent = laya_mlx.load(model_path, dtype="float16")
119
+ except Exception:
120
+ return {"error": "INCOMPATIBLE_LOCAL_MODEL"}
121
+ result = agent.predict(request["state"], request["questions"])
122
+ result["model"] = model
123
+ return result
124
+
125
+
126
+ def invoke(provider, model, request, timeout):
127
+ env = {k: v for k, v in os.environ.items()
128
+ if not any(part in k.upper() for part in ("KEY", "TOKEN", "SECRET", "PASSWORD", "CREDENTIAL"))}
129
+ if provider == "jev" and os.environ.get("TYPESAFE_API_KEY"):
130
+ env["TYPESAFE_API_KEY"] = os.environ["TYPESAFE_API_KEY"]
131
+ process = subprocess.run([sys.executable, str(Path(__file__).resolve()), "--worker", provider, model],
132
+ input=json.dumps(request), capture_output=True, text=True,
133
+ timeout=timeout, env=env)
134
+ if process.returncode:
135
+ raise ValueError("provider_failed")
136
+ return json.loads(process.stdout)
137
+
138
+
139
+ def evaluate(requests, configuration, approved=False, call=invoke):
140
+ cfg = configuration["config"]
141
+ results = []
142
+ attempted = 0
143
+ for request in requests:
144
+ reason = None
145
+ if configuration["status"] == "INVALID_CONFIG_CORE_FALLBACK":
146
+ reason = "INVALID_CONFIG"
147
+ elif cfg["mode"] == "off" or cfg["provider"] == "core":
148
+ reason = "OFF"
149
+ elif not approved:
150
+ reason = "DATA_NOT_APPROVED"
151
+ elif attempted >= cfg["max_calls_per_run"]:
152
+ reason = "CALL_LIMIT"
153
+ started = time.monotonic()
154
+ result = {"status": "CORE_FALLBACK", "provider": cfg["provider"], "model": cfg["model"]}
155
+ if reason is None:
156
+ try:
157
+ validate_request(request)
158
+ attempted += 1
159
+ response = call(cfg["provider"], cfg["model"], request, cfg["timeout_seconds"])
160
+ if isinstance(response, dict) and response.get("error"):
161
+ allowed = {"MISSING_TYPESAFE_API_KEY", "UNSUPPORTED_LOCAL_RUNTIME", "MISSING_LOCAL_RUNTIME",
162
+ "MISSING_LOCAL_MODEL", "INCOMPATIBLE_LOCAL_MODEL", "UNKNOWN_PROVIDER"}
163
+ reason = response["error"] if isinstance(response["error"], str) and response["error"] in allowed else "INVALID_OR_UNAVAILABLE"
164
+ else:
165
+ result["answers"] = validate_answers(request, response)
166
+ result["status"] = "SHADOW_SUGGESTION"
167
+ model = response.get("model")
168
+ result["model"] = model if isinstance(model, str) and len(model) <= 128 else cfg["model"]
169
+ except subprocess.TimeoutExpired:
170
+ reason = "TIMEOUT"
171
+ except (ValueError, TypeError, KeyError, OSError):
172
+ reason = "INVALID_OR_UNAVAILABLE"
173
+ result.update(reason=reason, calls_used=attempted,
174
+ elapsed_seconds=round(time.monotonic() - started, 6))
175
+ results.append(result)
176
+ return {"status": "ADVISORY_ONLY", "calls_used": attempted, "results": results}
177
+
178
+
179
+ def main():
180
+ if len(sys.argv) == 4 and sys.argv[1] == "--worker":
181
+ try:
182
+ # Third-party stdout may contain progress or input; contain it.
183
+ import contextlib
184
+ import io
185
+ request = validate_request(json.load(sys.stdin))
186
+ with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()):
187
+ result = worker(sys.argv[2], sys.argv[3], request)
188
+ print(json.dumps(result))
189
+ return 0
190
+ except Exception:
191
+ print(json.dumps({"error": "PROVIDER_UNAVAILABLE"}))
192
+ return 2
193
+ parser = argparse.ArgumentParser(description=__doc__)
194
+ parser.add_argument("--config", type=Path, default=Path(".nobrainer/flow.json"))
195
+ parser.add_argument("--request", type=Path, help="JSON array of requests; otherwise stdin")
196
+ parser.add_argument("--approved-data", action="store_true", help="Only with existing approval for this data and provider")
197
+ args = parser.parse_args()
198
+ try:
199
+ data = json.loads(args.request.read_text()) if args.request else json.load(sys.stdin)
200
+ if not isinstance(data, list) or len(data) > 100:
201
+ raise ValueError("batch")
202
+ print(json.dumps(evaluate(data, read(args.config), args.approved_data), ensure_ascii=False))
203
+ return 0
204
+ except (OSError, ValueError, TypeError):
205
+ print(json.dumps({"status": "CORE_FALLBACK", "reason": "INVALID_INPUT"}))
206
+ return 2
207
+
208
+
209
+ if __name__ == "__main__":
210
+ raise SystemExit(main())