nobrainer-tech-flow 2.4.1 → 2.6.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/README.md +14 -8
  5. package/docs/INSTALL.md +36 -22
  6. package/docs/MIGRATION_TO_FLOW.md +14 -0
  7. package/docs/SKILL_CURATION.md +8 -2
  8. package/docs/releases/v2.5.0.md +190 -0
  9. package/docs/releases/v2.6.0.md +73 -0
  10. package/gemini-extension.json +1 -1
  11. package/package.json +1 -1
  12. package/plugin.json +1 -1
  13. package/scripts/install.py +16 -7
  14. package/scripts/install.sh +129 -17
  15. package/scripts/install_personalization.py +51 -16
  16. package/scripts/install_skills.py +34 -5
  17. package/scripts/recommend_flow_setup.py +102 -12
  18. package/scripts/validate_skills.py +2 -1
  19. package/skills/nobrainer-build/SKILL.md +6 -2
  20. package/skills/nobrainer-context/SKILL.md +113 -0
  21. package/skills/nobrainer-context/references/clients.md +115 -0
  22. package/skills/nobrainer-context/references/consolidate.md +66 -0
  23. package/skills/nobrainer-context/scripts/context_inventory.py +425 -0
  24. package/skills/nobrainer-skill-doctor/SKILL.md +4 -4
  25. package/skills/nobrainer-tech-flow/SKILL.md +3 -3
  26. package/skills/nobrainer-tech-flow/VERSION +1 -1
  27. package/skills/nobrainer-tech-flow/references/daily-update.md +5 -2
  28. package/skills/nobrainer-tech-flow/references/delivery.md +17 -2
  29. package/skills/nobrainer-tech-flow/references/optional-decisions.md +5 -3
  30. package/skills/nobrainer-tech-flow/references/routing.md +1 -1
  31. package/skills/nobrainer-tech-flow/scripts/typed_decisions.py +20 -9
  32. package/skills/nobrainer-codex-context/SKILL.md +0 -111
  33. /package/skills/{nobrainer-codex-context → nobrainer-context}/scripts/codex_context.py +0 -0
@@ -0,0 +1,425 @@
1
+ #!/usr/bin/env python3
2
+ """Read-only inventory of the project instruction files coding agents load.
3
+
4
+ Lists, per client, the instruction files it reads in one project, their size
5
+ against documented limits, lines repeated across files, which file imports
6
+ which, and whether instructions point to the current Flow goal file. It never
7
+ writes. Client rules and sources: ../references/clients.md.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import glob
14
+ import hashlib
15
+ import json
16
+ import os
17
+ import re
18
+ import sys
19
+ from pathlib import Path
20
+ from typing import Any
21
+
22
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
23
+ import codex_context # noqa: E402
24
+
25
+ CLIENTS = ("codex", "claude", "gemini", "cursor", "copilot", "opencode")
26
+ # Documented guidance, not hard limits (see references/clients.md).
27
+ LINE_GUIDANCE = {"claude": 200, "cursor": 500}
28
+ CLAUDE_HARD_BYTES = 4 * 1024 * 1024
29
+ IMPORT_DEPTH = {"claude": 4, "gemini": 5}
30
+ # Modes that put a file in context at session start (Copilot: agent surfaces only).
31
+ LOADED = ("launch", "import", "global", "agent_surfaces")
32
+ MIN_DUPLICATE_CHARS = 40
33
+ MAX_READ_BYTES = 4 * 1024 * 1024
34
+ IMPORT_RE = re.compile(r"(?:^|\s)@((?:~/|\.{0,2}/)?[\w.\-/]*\w)")
35
+ GOAL_ID_RE = re.compile(r"^GOAL_ID:\s*\S", re.MULTILINE)
36
+ GOAL_STATUS_RE = re.compile(r"^STATUS:\s*([A-Z_]+)", re.MULTILINE)
37
+ GOAL_DIRS = ("", "tasks", "docs", ".nobrainer")
38
+
39
+
40
+ def global_file(client: str) -> Path | None:
41
+ """The first user-level instruction file the client documents, if present."""
42
+
43
+ env = os.environ
44
+ codex = Path(env.get("CODEX_HOME") or "~/.codex").expanduser()
45
+ claude = Path(env.get("CLAUDE_CONFIG_DIR") or "~/.claude").expanduser()
46
+ config = Path(env.get("XDG_CONFIG_HOME") or "~/.config").expanduser()
47
+ candidates = {
48
+ "codex": (codex / "AGENTS.override.md", codex / "AGENTS.md"),
49
+ "claude": (claude / "CLAUDE.md",),
50
+ "gemini": (Path("~/.gemini/GEMINI.md").expanduser(),),
51
+ "copilot": (Path("~/.copilot/copilot-instructions.md").expanduser(),),
52
+ "opencode": (config / "opencode" / "AGENTS.md", Path("~/.claude/CLAUDE.md").expanduser()),
53
+ }.get(client, ())
54
+ return next((path for path in candidates if path.is_file()), None)
55
+
56
+
57
+ def read_bytes(path: Path) -> bytes | None:
58
+ try:
59
+ return path.read_bytes()
60
+ except OSError:
61
+ return None
62
+
63
+
64
+ def read_text(path: Path) -> str:
65
+ try:
66
+ with path.open("rb") as handle:
67
+ return handle.read(MAX_READ_BYTES).decode("utf-8", errors="replace")
68
+ except OSError:
69
+ return ""
70
+
71
+
72
+ def chain(root: Path, cwd: Path) -> list[Path]:
73
+ return codex_context.search_dirs(root, cwd)
74
+
75
+
76
+ def prose(text: str) -> str:
77
+ """Text outside fenced code blocks and inline code spans."""
78
+
79
+ # Blank fenced blocks but keep their line breaks, so line numbers stay true.
80
+ text = re.sub(r"^(```|~~~).*?^\1", lambda m: "\n" * m.group(0).count("\n"), text,
81
+ flags=re.MULTILINE | re.DOTALL)
82
+ return re.sub(r"`[^`\n]*`", "", text)
83
+
84
+
85
+ def imports(path: Path, depth: int, seen: set[Path] | None = None,
86
+ missing: list[tuple[Path, int, str]] | None = None) -> list[Path]:
87
+ """Files path imports with @path, recursively; unresolved path-like imports go to missing."""
88
+
89
+ seen = seen if seen is not None else {path.resolve()}
90
+ found: list[Path] = []
91
+ if depth <= 0:
92
+ return found
93
+ for number, line in enumerate(prose(read_text(path)).splitlines(), 1):
94
+ for match in IMPORT_RE.finditer(line):
95
+ name = match.group(1)
96
+ target = (Path(name).expanduser() if name.startswith("~") else path.parent / name).resolve()
97
+ if not target.is_file():
98
+ # A path, not a handle such as @alice: it names a file that is not there.
99
+ if missing is not None and ("/" in name or re.search(r"\.[A-Za-z0-9]+$", name)):
100
+ missing.append((path, number, name))
101
+ continue
102
+ if target not in seen:
103
+ seen.add(target)
104
+ found.append(target)
105
+ found.extend(imports(target, depth - 1, seen, missing))
106
+ return found
107
+
108
+
109
+ def frontmatter(path: Path) -> str:
110
+ text = read_text(path)
111
+ if not text.startswith("---"):
112
+ return ""
113
+ end = text.find("\n---", 3)
114
+ return text[3:end] if end > 0 else ""
115
+
116
+
117
+ def present(*paths: Path) -> list[Path]:
118
+ return [path for path in paths if path.is_file()]
119
+
120
+
121
+ def strip_jsonc(text: str) -> str:
122
+ """JSON from JSONC: comments outside strings and trailing commas removed."""
123
+
124
+ out: list[str] = []
125
+ index, quoted = 0, False
126
+ while index < len(text):
127
+ char = text[index]
128
+ if quoted:
129
+ out.append(char)
130
+ if char == "\\":
131
+ out.append(text[index + 1:index + 2])
132
+ index += 1
133
+ elif char == '"':
134
+ quoted = False
135
+ elif char == '"':
136
+ quoted = True
137
+ out.append(char)
138
+ elif text.startswith("//", index):
139
+ end = text.find("\n", index)
140
+ index = len(text) if end < 0 else end
141
+ continue
142
+ elif text.startswith("/*", index):
143
+ end = text.find("*/", index + 2)
144
+ if end < 0:
145
+ raise ValueError("unterminated block comment")
146
+ out.append(" ")
147
+ index = end + 2
148
+ continue
149
+ else:
150
+ out.append(char)
151
+ index += 1
152
+ # Match complete strings first so comma/bracket bytes in paths stay literal.
153
+ return re.sub(r'"(?:\\.|[^"\\])*"|,(\s*[}\]])',
154
+ lambda match: match.group(1) if match.group(1) is not None else match.group(0),
155
+ "".join(out))
156
+
157
+
158
+ def project_settings(path: Path, problems: list[str], client: str, root: Path) -> dict[str, Any]:
159
+ """A project settings file as a dict; a file that cannot be parsed is a problem."""
160
+
161
+ if not path.is_file():
162
+ return {}
163
+ try:
164
+ value = json.loads(strip_jsonc(read_text(path)))
165
+ except ValueError as exc:
166
+ problems.append(f"{client}: {label(path, root)} cannot be parsed: {exc}")
167
+ return {}
168
+ return value if isinstance(value, dict) else {}
169
+
170
+
171
+ SKIP_DIRS = {"node_modules", "vendor", "dist", "build", "target", "venv", "__pycache__"}
172
+ MAX_DESCENDANT_DIRS = 5000
173
+
174
+
175
+ def descendants(cwd: Path) -> list[Path]:
176
+ """Folders below cwd that may hold scoped instructions; hidden and vendored ones skipped."""
177
+
178
+ found: list[Path] = []
179
+ for current, names, _ in os.walk(cwd):
180
+ names[:] = sorted(n for n in names if not n.startswith(".") and n not in SKIP_DIRS)
181
+ if Path(current) != cwd:
182
+ found.append(Path(current))
183
+ if len(found) >= MAX_DESCENDANT_DIRS:
184
+ break
185
+ return found
186
+
187
+
188
+ def client_files(client: str, root: Path, cwd: Path, codex_config: Path,
189
+ problems: list[str], include_global: bool = False) -> list[tuple[Path, str]]:
190
+ """Return (file, load mode) pairs one client reads for this project.
191
+
192
+ Problems found on the way (a settings file that cannot be parsed, an import of a
193
+ file that does not exist) are appended to problems.
194
+ """
195
+
196
+ dirs = chain(root, cwd)
197
+ below = descendants(cwd)
198
+ files: list[tuple[Path, str]] = []
199
+ if client == "codex":
200
+ result = codex_context.inspect_context(cwd, codex_config)
201
+ problems.extend(f"codex: {error}" for error in result["config_errors"])
202
+ files = [(Path(doc["path"]), "launch") for doc in result["documents"]]
203
+ elif client == "claude":
204
+ # Claude Code reads every folder from the filesystem root down, not only the project.
205
+ home = Path.home().resolve()
206
+ ancestors = list(reversed([cwd, *cwd.parents]))
207
+
208
+ def claude_names(folder: Path) -> list[Path]:
209
+ # ~/.claude/CLAUDE.md is the user file: it loads anyway and does not stop AGENTS.md.
210
+ dot = [] if folder == home else [folder / ".claude" / "CLAUDE.md"]
211
+ return present(folder / "CLAUDE.md", *dot, folder / "CLAUDE.local.md")
212
+
213
+ claude = [p for d in ancestors for p in claude_names(d)]
214
+ agents = [p for d in ancestors for p in present(d / "AGENTS.md", d / ".claude" / "AGENTS.md")]
215
+ files = [(p, "launch") for p in (claude or agents)]
216
+ for rule in sorted((root / ".claude" / "rules").rglob("*.md")):
217
+ scoped = re.search(r"^paths\s*:", frontmatter(rule), re.MULTILINE)
218
+ files.append((rule, "on_demand" if scoped else "launch"))
219
+ for folder in below:
220
+ own = claude_names(folder)
221
+ files += [(p, "on_demand") for p in own or (present(folder / "AGENTS.md") if not claude else [])]
222
+ elif client == "gemini":
223
+ settings = project_settings(root / ".gemini" / "settings.json", problems, client, root)
224
+ names = settings.get("context", {})
225
+ names = names.get("fileName", "GEMINI.md") if isinstance(names, dict) else "GEMINI.md"
226
+ names = [names] if isinstance(names, str) else [n for n in names if isinstance(n, str)] or ["GEMINI.md"]
227
+ files = [(p, "launch") for d in dirs for p in present(*(d / n for n in names))]
228
+ files += [(p, "on_demand") for d in below for p in present(*(d / n for n in names))]
229
+ elif client == "cursor":
230
+ for rule in sorted((root / ".cursor" / "rules").rglob("*.mdc")):
231
+ always = re.search(r"^alwaysApply\s*:\s*true", frontmatter(rule), re.MULTILINE)
232
+ files.append((rule, "launch" if always else "conditional"))
233
+ files += [(p, "launch") for d in dirs for p in present(d / "AGENTS.md")]
234
+ files += [(p, "conditional") for d in below for p in present(d / "AGENTS.md")]
235
+ files += [(p, "legacy") for p in present(root / ".cursorrules")]
236
+ elif client == "copilot":
237
+ files = [(p, "launch") for p in present(root / ".github" / "copilot-instructions.md")]
238
+ files += [(p, "conditional") for p in sorted((root / ".github" / "instructions").rglob("*.instructions.md"))]
239
+ nearest = [p for d in reversed(dirs) for p in present(d / "AGENTS.md")][:1]
240
+ files += [(p, "agent_surfaces") for p in nearest + present(root / "CLAUDE.md", root / "GEMINI.md")]
241
+ files += [(p, "conditional") for d in below for p in present(d / "AGENTS.md")]
242
+ elif client == "opencode":
243
+ up = list(reversed(dirs))
244
+ agents = [p for d in up for p in present(d / "AGENTS.md")][:1]
245
+ files = [(p, "launch") for p in agents or [p for d in up for p in present(d / "CLAUDE.md")][:1]]
246
+ for config in ("opencode.json", "opencode.jsonc"):
247
+ entries = project_settings(root / config, problems, client, root).get("instructions", [])
248
+ for entry in entries if isinstance(entries, list) else []:
249
+ if not isinstance(entry, str) or not entry.strip() or entry.startswith(("http://", "https://")):
250
+ continue
251
+ pattern = os.path.join(str(root), os.path.expanduser(entry))
252
+ files += [(Path(p), "launch") for p in sorted(glob.glob(pattern, recursive=True)) if Path(p).is_file()]
253
+ user_file = global_file(client) if include_global else None
254
+ if user_file is not None:
255
+ files.append((user_file, "global"))
256
+ depth = IMPORT_DEPTH.get(client, 0)
257
+ missing: list[tuple[Path, int, str]] = []
258
+ expanded: list[tuple[Path, str]] = []
259
+ for path, mode in files:
260
+ expanded.append((path, mode))
261
+ import_mode = "import" if mode in LOADED else mode
262
+ expanded += [(extra, import_mode) for extra in imports(path, depth, missing=missing)]
263
+ for path, number, name in dict.fromkeys(missing):
264
+ problems.append(f"{client}: {label(path, root)}:{number} imports {name}, which does not exist")
265
+ unique: dict[Path, tuple[Path, str]] = {}
266
+ for path, mode in expanded:
267
+ key = path.resolve()
268
+ if key not in unique or (mode in LOADED and unique[key][1] not in LOADED):
269
+ unique[key] = (path, mode)
270
+ return list(unique.values())
271
+
272
+
273
+ def normalize(line: str) -> str:
274
+ line = re.sub(r"^\s*(?:[-*+]|\d+[.)])\s+", "", line.strip())
275
+ return re.sub(r"\s+", " ", line).lower()
276
+
277
+
278
+ def duplicates(paths: list[Path], root: Path) -> list[dict[str, Any]]:
279
+ seen: dict[str, list[str]] = {}
280
+ for path in paths:
281
+ for number, line in enumerate(prose(read_text(path)).splitlines(), 1):
282
+ key = normalize(line)
283
+ if len(key) >= MIN_DUPLICATE_CHARS and not key.startswith(("#", "<!--", "|")):
284
+ where = f"{label(path, root)}:{number}"
285
+ if not any(item.startswith(label(path, root) + ":") for item in seen.setdefault(key, [])):
286
+ seen[key].append(where)
287
+ return [{"text": key[:100], "locations": spots} for key, spots in seen.items() if len(spots) > 1]
288
+
289
+
290
+ def label(path: Path, root: Path) -> str:
291
+ try:
292
+ return path.resolve().relative_to(root.resolve()).as_posix()
293
+ except ValueError:
294
+ home = Path.home().resolve()
295
+ resolved = path.resolve()
296
+ return "~/" + resolved.relative_to(home).as_posix() if resolved.is_relative_to(home) else str(resolved)
297
+
298
+
299
+ def describe(path: Path, root: Path) -> dict[str, Any]:
300
+ data = read_bytes(path)
301
+ raw = data or b""
302
+ text = raw.decode("utf-8", errors="replace")
303
+ return {
304
+ "path": label(path, root),
305
+ "readable": data is not None,
306
+ "bytes": len(raw),
307
+ "lines": text.count("\n") + (0 if text.endswith("\n") or not text else 1),
308
+ "words": len(text.split()),
309
+ "sha256": hashlib.sha256(raw).hexdigest(),
310
+ "imports": [label(p, root) for p in imports(path, 1)],
311
+ }
312
+
313
+
314
+ def goal_state(root: Path, texts: dict[str, str], explicit: Path | None) -> dict[str, Any]:
315
+ candidates = [explicit] if explicit else [
316
+ path for sub in GOAL_DIRS for pattern in ("*.md", "*/*.md")
317
+ for path in sorted((root / sub).glob(pattern)) if sub or pattern == "*.md"
318
+ ]
319
+ goals = []
320
+ for path in candidates:
321
+ text = read_text(path) if path and path.is_file() and path.stat().st_size <= 256 * 1024 else ""
322
+ if GOAL_ID_RE.search(text):
323
+ status = GOAL_STATUS_RE.search(text)
324
+ goals.append({"path": label(path, root), "status": status.group(1) if status else "UNKNOWN"})
325
+ active = [g for g in goals if g["status"] not in ("COMPLETE",)]
326
+ mentioned = {g["path"] for g in goals for t in texts.values() if g["path"] in t}
327
+ dangling = sorted({m for t in texts.values() for m in re.findall(r"[\w./-]*goal[\w./-]*\.md", t, re.I)
328
+ if not (root / m).is_file()})
329
+ if not active:
330
+ status = "STALE_POINTER" if dangling else "NONE"
331
+ elif any(g["path"] in mentioned for g in active):
332
+ status = "POINTED"
333
+ else:
334
+ status = "STALE_POINTER" if mentioned or dangling else "NOT_POINTED"
335
+ return {"status": status, "goal_files": goals, "mentioned": sorted(mentioned), "missing_paths": dangling}
336
+
337
+
338
+ def inventory(cwd: Path, codex_config: Path, clients: tuple[str, ...], include_global: bool,
339
+ goal_file: Path | None) -> dict[str, Any]:
340
+ root, _ = codex_context.find_root(cwd, codex_context.DEFAULT_MARKERS)
341
+ root = root or cwd
342
+ report: dict[str, Any] = {"schema": 1, "project_root": str(root), "clients": {}}
343
+ all_files: dict[Path, Path] = {}
344
+ findings: list[str] = []
345
+ for client in clients:
346
+ loaded = client_files(client, root, cwd, codex_config, findings, include_global)
347
+ entries = []
348
+ for path, mode in loaded:
349
+ all_files.setdefault(path.resolve(), path)
350
+ entry = describe(path, root) | {"mode": mode}
351
+ if not entry["readable"]:
352
+ findings.append(f"{client}: {entry['path']} cannot be read")
353
+ guidance = LINE_GUIDANCE.get(client)
354
+ if client == "claude" and path.name != "CLAUDE.md":
355
+ guidance = None
356
+ entry["over_guidance"] = bool(guidance and mode != "import" and entry["lines"] > guidance)
357
+ if entry["over_guidance"]:
358
+ findings.append(f"{client}: {entry['path']} has {entry['lines']} lines (guidance {guidance})")
359
+ if client == "claude" and entry["bytes"] > CLAUDE_HARD_BYTES:
360
+ findings.append(f"claude: {entry['path']} is over 4 MiB and is skipped")
361
+ entries.append(entry)
362
+ doubled = duplicates([p for p, m in loaded if m in LOADED], root)
363
+ if doubled:
364
+ findings.append(f"{client}: {len(doubled)} line(s) loaded more than once")
365
+ info: dict[str, Any] = {"files": entries, "launch_bytes": sum(
366
+ e["bytes"] for e in entries if e["mode"] in LOADED), "repeated_lines": len(doubled)}
367
+ if client == "codex":
368
+ probe = codex_context.inspect_context(cwd, codex_config)
369
+ info |= {"byte_budget": probe["project_doc_max_bytes"], "truncated": probe["status"] == "TRUNCATED"}
370
+ if info["truncated"]:
371
+ findings.append("codex: project instructions exceed project_doc_max_bytes and are truncated")
372
+ report["clients"][client] = info
373
+ paths = list(all_files.values())
374
+ report["files"] = sorted({label(p, root) for p in paths})
375
+ digests: dict[str, list[str]] = {}
376
+ for path in paths:
377
+ data = read_bytes(path)
378
+ if data is not None:
379
+ digests.setdefault(hashlib.sha256(data).hexdigest(), []).append(label(path, root))
380
+ report["identical_copies"] = [sorted(group) for group in digests.values() if len(group) > 1]
381
+ report["duplicates"] = duplicates(paths, root)
382
+ report["goal"] = goal_state(root, {label(p, root): read_text(p) for p in paths}, goal_file)
383
+ if report["identical_copies"]:
384
+ findings.append(f"{len(report['identical_copies'])} set(s) of byte-identical instruction files")
385
+ if report["goal"]["status"] in ("NOT_POINTED", "STALE_POINTER"):
386
+ findings.append(f"goal: {report['goal']['status']}")
387
+ source = root / "AGENTS.md"
388
+ report["source_of_truth_candidate"] = "AGENTS.md" if source.is_file() else None
389
+ report["findings"] = findings
390
+ report["status"] = "MISSING" if not paths else "ATTENTION" if findings else "CURRENT"
391
+ return report
392
+
393
+
394
+ def main() -> int:
395
+ parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
396
+ parser.add_argument("--cwd", type=Path, default=Path.cwd())
397
+ parser.add_argument("--codex-config", type=Path, help="Codex config.toml (default: CODEX_HOME or ~/.codex)")
398
+ parser.add_argument("--client", action="append", choices=CLIENTS, help="limit to a client; repeatable")
399
+ parser.add_argument("--include-global", action="store_true", help="also size each client's user-level file")
400
+ parser.add_argument("--goal-file", type=Path, help="Flow goal file to check instead of searching")
401
+ parser.add_argument("--json", action="store_true", help="print machine-readable JSON")
402
+ parser.add_argument("--check", action="store_true", help="exit 1 unless the status is CURRENT")
403
+ args = parser.parse_args()
404
+ cwd = args.cwd.expanduser().resolve()
405
+ config = (args.codex_config or Path(os.environ.get("CODEX_HOME", "~/.codex")) / "config.toml").expanduser()
406
+ goal = args.goal_file.expanduser().resolve() if args.goal_file else None
407
+ report = inventory(cwd, config.resolve(), tuple(args.client or CLIENTS), args.include_global, goal)
408
+ if args.json:
409
+ print(json.dumps(report, indent=2, sort_keys=True))
410
+ else:
411
+ print(f"CONTEXT: {report['status']}")
412
+ print(f"PROJECT_ROOT: {report['project_root']}")
413
+ for client, info in report["clients"].items():
414
+ names = ", ".join(f"{e['path']} ({e['mode']}, {e['bytes']} B)" for e in info["files"]) or "NONE"
415
+ print(f"{client.upper()}: {names}")
416
+ print(f"IDENTICAL_COPIES: {report['identical_copies'] or 'NONE'}")
417
+ print(f"REPEATED_LINES: {len(report['duplicates'])}")
418
+ print(f"GOAL: {report['goal']['status']}")
419
+ for finding in report["findings"]:
420
+ print(f"FINDING: {finding}")
421
+ return 1 if args.check and report["status"] != "CURRENT" else 0
422
+
423
+
424
+ if __name__ == "__main__":
425
+ sys.exit(main())
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: nobrainer-skill-doctor
3
- description: "Use when the owner says nb-skill-doctor, requests a skill or task-prompt quality audit, or reviews instruction conflicts across projects. For which AGENTS files Codex loads in one project, use nobrainer-codex-context."
3
+ description: "Use when the owner says nb-skill-doctor, requests a skill or task-prompt quality audit, or reviews instruction conflicts across projects. For which instruction files coding agents load in one project, use nobrainer-context."
4
4
  ---
5
5
 
6
6
  # NoBrainer Skill Doctor
@@ -105,8 +105,8 @@ their owner gate. A model/client behavior case that was not run is `UNTESTED`.
105
105
 
106
106
  ## Ownership boundaries
107
107
 
108
- - Use `nobrainer-codex-context` for one project's Codex instruction discovery,
109
- context setup or repair.
108
+ - Use `nobrainer-context` for one project's instruction files across coding
109
+ agents: discovery, consolidation or repair.
110
110
  - Use `nobrainer-review` for the exact diff, bug hunt or release closeout; the
111
111
  doctor does not replace its final evidence gate.
112
112
  - Use `nobrainer-build` for an accepted code, documentation or configuration fix.
@@ -123,7 +123,7 @@ review, capability installation or delivery orchestration.
123
123
 
124
124
  If repair is explicitly requested, freeze the source/ref and write only to the
125
125
  canonical target. Preserve user changes and managed blocks. Route context edits
126
- to `nobrainer-codex-context`, implementation edits to `nobrainer-build`, and a
126
+ to `nobrainer-context`, implementation edits to `nobrainer-build`, and a
127
127
  consequential closeout to `nobrainer-review`. Do not silently change model
128
128
  selection, install a package, delete a skill, commit, push or publish.
129
129
 
@@ -42,9 +42,9 @@ Skip ledger, delegation, review and startup care. Escalate to the full Flow life
42
42
  for ambiguity, architecture, public behavior, routing or consequential
43
43
  side effects; quick path never bypasses an owner gate.
44
44
 
45
- For substantial work, delegate all independent ready units promptly through
46
- [team execution](references/delivery.md#coordinated-team-execution); MAIN coordinates,
47
- integrates, verifies and refills slots. Use [Auto Fine Tune](../nobrainer-auto-fine-tune/SKILL.md)
45
+ Delegate aggressively: run substantial work's independent units
46
+ on parallel native subagents through [team execution](references/delivery.md#coordinated-team-execution);
47
+ MAIN integrates, verifies and refills slots. Use [Auto Fine Tune](../nobrainer-auto-fine-tune/SKILL.md)
48
48
  for first-setup capability checks; benchmark only when calibration pays.
49
49
  Unavailable native workers leave work in MAIN; visible conversations require explicit owner request
50
50
  or authorized MAIN restart; isolation, reuse or handoff alone never authorize one.
@@ -1 +1 @@
1
- 2.4.1
1
+ 2.6.0
@@ -10,8 +10,11 @@ the client is current. A failed network check is `CHECK_FAILED`, not
10
10
  `CURRENT`.
11
11
 
12
12
  When `UPDATE_AVAILABLE`, tell the user the installed and latest version
13
- and the verified release link. Upgrade only within the owner's recorded
14
- standing authorization or after approval of the exact reviewed change.
13
+ and the verified release link. The default instruction line ("Once a day on
14
+ first use, update nobrainer-tech-flow...") authorizes this Flow-only upgrade to a
15
+ verified stable release; the opt-out line ("tell the owner... do not update it
16
+ yourself") does not. Otherwise upgrade only after approval of the exact reviewed
17
+ change.
15
18
  Before applying, obtain the new release source at a verified immutable ref,
16
19
  compare installation targets, run `validate_skills.py --suite`, then dry-run
17
20
  `install_skills.py` and `install_personalization.py`. Preserve foreign
@@ -115,6 +115,12 @@ useful authorized work remains. Never turn an unknown right/license into consent
115
115
 
116
116
  ## Keep review proportional to progress
117
117
 
118
+ Scale verification to blast radius. A reversible, low-risk change may proceed on
119
+ 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
+
118
124
  Review the next executable slice against concrete acceptance and failure paths.
119
125
  For a stable slice, default to one review and one focused correction verification.
120
126
  More review needs a newly evidenced material defect or changed risk, not a new
@@ -136,7 +142,14 @@ reviews, tests or generated files.
136
142
  ## Coordinated team execution
137
143
 
138
144
  MAIN owns the full outcome, coordination, critical path, dependencies, integration,
139
- owner decisions and acceptance. Before executing non-trivial work or entering a
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
140
153
  blocking wait, identify independent ready units and dispatch every useful eligible
141
154
  unit promptly to native workers within verified host capacity and authorized usage
142
155
  limits. Eligibility requires accepted dependencies, authority, a supported route
@@ -197,7 +210,9 @@ worker completion, MAIN reconciles the exact returned ID, inspects the current
197
210
  artifact, assigned scope and required proof, and verifies that the worker has
198
211
  stopped writing. Then release only that unit's proven ownership, recompute readiness
199
212
  and dispatch newly ready work without waiting for unrelated slow workers. A
200
- worker's report, exit code or self-score does not accept its artifact.
213
+ worker's report, exit code or self-score does not accept its artifact. When a batch
214
+ partly fails, resend only the failed units with their new evidence; never rerun the
215
+ accepted ones.
201
216
 
202
217
  A waiting, suspended or uncertain worker has a stated reason and a bounded next
203
218
  observation in the current tracker. Reserve its scope and possible capacity until
@@ -30,8 +30,10 @@ Jev/Laya records that preference in off mode until data/cost authority is known.
30
30
  `set` is an explicit configuration change. `show` never writes or uses a provider.
31
31
  Malformed config leaves its bytes intact, reports core fallback and does not
32
32
  re-open consent. `off` disables calls even with provider settings present.
33
- The configuration schema accepts no credential fields. Put the TypeSafe key in
34
- the invoking process's `TYPESAFE_API_KEY`; do not print it or put it in argv.
33
+ The configuration schema accepts no credential fields. The helper reads no key
34
+ from the machine unless you name the environment variable that holds it with
35
+ `--key-env` (for example `--key-env TYPESAFE_API_KEY`); do not print the key or
36
+ put its value in argv.
35
37
 
36
38
  ## Use bounded suggestions
37
39
 
@@ -53,7 +55,7 @@ a model. Example synthetic request:
53
55
  ```
54
56
 
55
57
  ```sh
56
- python3 <flow-skill-dir>/scripts/typed_decisions.py --request requests.json --approved-data
58
+ python3 <flow-skill-dir>/scripts/typed_decisions.py --request requests.json --approved-data --key-env TYPESAFE_API_KEY
57
59
  ```
58
60
 
59
61
  `--approved-data` records existing scope authorization, not permission invented
@@ -9,7 +9,7 @@ trigger applies.
9
9
  | Need | Primary method |
10
10
  |---|---|
11
11
  | lifecycle, brief requirements, visible progress and guarded autonomy | `nobrainer-tech-flow` |
12
- | project-local Codex instruction discovery, byte-budget audit and context readback | `nobrainer-codex-context` |
12
+ | one project's instruction files across coding agents: what loads, size, duplicates, contradictions, one source of truth | `nobrainer-context` |
13
13
  | cross-project instruction, skill and task-prompt audit with coverage planning | `nobrainer-skill-doctor` |
14
14
  | capability selection, bounded native assignments or open-skill evaluation | `nobrainer-team` |
15
15
  | ready-set calculation, dependency batches, bounded dispatch and backpressure | `nobrainer-dispatcher` |
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env python3
2
2
  """Bounded advisory typed decisions; never executes selected actions."""
3
3
  import argparse
4
+ import functools
4
5
  import json
5
6
  import math
6
7
  import os
@@ -81,9 +82,8 @@ class NoRedirect(urllib.request.HTTPRedirectHandler):
81
82
  raise ValueError("redirect_not_allowed")
82
83
 
83
84
 
84
- def worker(provider, model, request):
85
+ def worker(provider, model, request, key=None):
85
86
  if provider == "jev":
86
- key = os.environ.get("TYPESAFE_API_KEY")
87
87
  if not key:
88
88
  return {"error": "MISSING_TYPESAFE_API_KEY"}
89
89
  payload = json.dumps(dict(request, model=model)).encode()
@@ -123,13 +123,19 @@ def worker(provider, model, request):
123
123
  return result
124
124
 
125
125
 
126
- def invoke(provider, model, request, timeout):
126
+ def invoke(provider, model, request, timeout, key_env=None):
127
+ # The child gets no credential from the environment. The TypeSafe key is read only
128
+ # from the variable the caller names with --key-env, and passed on stdin.
127
129
  env = {k: v for k, v in os.environ.items()
128
130
  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
+ if key_env:
132
+ # The named variable may not look like a credential (TYPESAFE_AUTH, FOO); drop it anyway.
133
+ # Windows treats variable names without regard to case.
134
+ for name in [k for k in env if k == key_env or (os.name == "nt" and k.upper() == key_env.upper())]:
135
+ del env[name]
136
+ key = os.environ.get(key_env) if provider == "jev" and key_env else None
131
137
  process = subprocess.run([sys.executable, str(Path(__file__).resolve()), "--worker", provider, model],
132
- input=json.dumps(request), capture_output=True, text=True,
138
+ input=json.dumps({"request": request, "key": key}), capture_output=True, text=True,
133
139
  timeout=timeout, env=env)
134
140
  if process.returncode:
135
141
  raise ValueError("provider_failed")
@@ -182,9 +188,11 @@ def main():
182
188
  # Third-party stdout may contain progress or input; contain it.
183
189
  import contextlib
184
190
  import io
185
- request = validate_request(json.load(sys.stdin))
191
+ payload = json.load(sys.stdin)
192
+ request = validate_request(payload["request"])
193
+ key = payload.get("key") if isinstance(payload.get("key"), str) else None
186
194
  with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()):
187
- result = worker(sys.argv[2], sys.argv[3], request)
195
+ result = worker(sys.argv[2], sys.argv[3], request, key)
188
196
  print(json.dumps(result))
189
197
  return 0
190
198
  except Exception:
@@ -194,12 +202,15 @@ def main():
194
202
  parser.add_argument("--config", type=Path, default=Path(".nobrainer/flow.json"))
195
203
  parser.add_argument("--request", type=Path, help="JSON array of requests; otherwise stdin")
196
204
  parser.add_argument("--approved-data", action="store_true", help="Only with existing approval for this data and provider")
205
+ parser.add_argument("--key-env", metavar="NAME",
206
+ help="Jev only: the environment variable that holds your TypeSafe key; nothing is read without it")
197
207
  args = parser.parse_args()
198
208
  try:
199
209
  data = json.loads(args.request.read_text()) if args.request else json.load(sys.stdin)
200
210
  if not isinstance(data, list) or len(data) > 100:
201
211
  raise ValueError("batch")
202
- print(json.dumps(evaluate(data, read(args.config), args.approved_data), ensure_ascii=False))
212
+ call = functools.partial(invoke, key_env=args.key_env)
213
+ print(json.dumps(evaluate(data, read(args.config), args.approved_data, call), ensure_ascii=False))
203
214
  return 0
204
215
  except (OSError, ValueError, TypeError):
205
216
  print(json.dumps({"status": "CORE_FALLBACK", "reason": "INVALID_INPUT"}))