nobrainer-tech-flow 2.4.1 → 2.5.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/README.md +7 -5
- package/docs/INSTALL.md +9 -6
- package/docs/MIGRATION_TO_FLOW.md +14 -0
- package/docs/SKILL_CURATION.md +8 -2
- package/docs/releases/v2.5.0.md +190 -0
- package/gemini-extension.json +1 -1
- package/package.json +1 -1
- package/plugin.json +1 -1
- package/scripts/install.py +13 -6
- package/scripts/install.sh +105 -12
- package/scripts/install_skills.py +34 -5
- package/scripts/recommend_flow_setup.py +102 -12
- package/scripts/validate_skills.py +2 -1
- package/skills/nobrainer-context/SKILL.md +113 -0
- package/skills/nobrainer-context/references/clients.md +115 -0
- package/skills/nobrainer-context/references/consolidate.md +66 -0
- package/skills/nobrainer-context/scripts/context_inventory.py +425 -0
- package/skills/nobrainer-skill-doctor/SKILL.md +4 -4
- package/skills/nobrainer-tech-flow/VERSION +1 -1
- package/skills/nobrainer-tech-flow/references/optional-decisions.md +5 -3
- package/skills/nobrainer-tech-flow/references/routing.md +1 -1
- package/skills/nobrainer-tech-flow/scripts/typed_decisions.py +20 -9
- package/skills/nobrainer-codex-context/SKILL.md +0 -111
- /package/skills/{nobrainer-codex-context → nobrainer-context}/scripts/codex_context.py +0 -0
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# What each client loads
|
|
2
|
+
|
|
3
|
+
Checked against official documentation on 2026-10-20. Client behaviour changes;
|
|
4
|
+
re-check the cited page before relying on a number, and report anything newer as
|
|
5
|
+
fresh evidence. `UNVERIFIED` means the official pages read did not document it:
|
|
6
|
+
treat it as unknown, never as a limit. `context_inventory.py` implements the
|
|
7
|
+
project-local rules below.
|
|
8
|
+
|
|
9
|
+
## Codex
|
|
10
|
+
|
|
11
|
+
- Per directory from the project root (usually the Git root) down to the
|
|
12
|
+
working directory, at most one file: `AGENTS.override.md`, else `AGENTS.md`,
|
|
13
|
+
else the first name in `project_doc_fallback_filenames`. Files are joined
|
|
14
|
+
root first, so closer files appear later and win.
|
|
15
|
+
- Global: `AGENTS.override.md` or `AGENTS.md` in `CODEX_HOME` (default
|
|
16
|
+
`~/.codex`).
|
|
17
|
+
- Limit: `project_doc_max_bytes`, default 32 KiB; files past the limit are not
|
|
18
|
+
added. `project_root_markers` changes root detection; its default is
|
|
19
|
+
`UNVERIFIED` (the probe assumes `.git`).
|
|
20
|
+
- Imports: none documented.
|
|
21
|
+
- Readback: no command that prints the loaded instructions is documented
|
|
22
|
+
(`UNVERIFIED`); a model asked to list its sources is not a deterministic
|
|
23
|
+
readback. Use `codex_context.py` for the file chain.
|
|
24
|
+
- Sources: <https://developers.openai.com/codex/guides/agents-md>,
|
|
25
|
+
<https://developers.openai.com/codex/config-reference>.
|
|
26
|
+
|
|
27
|
+
## Claude Code
|
|
28
|
+
|
|
29
|
+
- Project: `CLAUDE.md` or `.claude/CLAUDE.md`, and `CLAUDE.local.md` (personal,
|
|
30
|
+
gitignored), in the working directory and every directory above it, joined
|
|
31
|
+
root first; subdirectory files load when Claude works there.
|
|
32
|
+
- Rules: `.claude/rules/**/*.md`. A rule without `paths:` frontmatter loads at
|
|
33
|
+
start; with `paths:` it loads only for matching files.
|
|
34
|
+
- `AGENTS.md`: read directly (v2.1.277 or later) only when no `CLAUDE.md`,
|
|
35
|
+
`.claude/CLAUDE.md` or `CLAUDE.local.md` exists in the working directory or
|
|
36
|
+
above. Adding any of them stops that unless the user-level setting
|
|
37
|
+
`claude-md-and-agents-md` is chosen. A `CLAUDE.md` with `@AGENTS.md` works on
|
|
38
|
+
every version and never reads `AGENTS.md` twice.
|
|
39
|
+
- Imports: `@path`, relative to the importing file, up to four hops; imported
|
|
40
|
+
files inherit the importing file's start-up or on-demand scope. With
|
|
41
|
+
`--include-global`, the probe also expands imports from the user-level
|
|
42
|
+
`~/.claude/CLAUDE.md`. Not expanded inside code spans or blocks.
|
|
43
|
+
- Limits: guidance of under 200 lines per `CLAUDE.md`; the probe applies it to
|
|
44
|
+
directly discovered `CLAUDE.md` files, not imported files, `AGENTS.md` fallbacks
|
|
45
|
+
or `.claude/rules/*.md` rules. A file over 4 MiB is skipped.
|
|
46
|
+
- Readback: `/context` (Memory files) and `/memory` in a fresh interactive
|
|
47
|
+
session; the `InstructionsLoaded` hook logs loads (not for `AGENTS.md` read
|
|
48
|
+
through the setting). `/doctor prompt-audit` (v2.1.283 or later) is a native
|
|
49
|
+
report of stale or conflicting instructions. No non-interactive command is
|
|
50
|
+
documented.
|
|
51
|
+
- Source: <https://code.claude.com/docs/en/memory>.
|
|
52
|
+
|
|
53
|
+
## Gemini CLI
|
|
54
|
+
|
|
55
|
+
- `~/.gemini/GEMINI.md`, then `GEMINI.md` in the workspace directories and
|
|
56
|
+
their parents, then files found when a tool reads a directory. All are joined.
|
|
57
|
+
- `context.fileName` in `settings.json` sets one name or a list, for example
|
|
58
|
+
`["AGENTS.md", "GEMINI.md"]`. The probe reads only project
|
|
59
|
+
`.gemini/settings.json`.
|
|
60
|
+
- Imports: `@path`, default depth 5, circular imports blocked. With
|
|
61
|
+
`--include-global`, the probe also expands imports from the user-level
|
|
62
|
+
`~/.gemini/GEMINI.md`, using the same size, duplicate and goal-pointer checks.
|
|
63
|
+
- Limit: none documented.
|
|
64
|
+
- Readback: `/memory show` prints the combined memory.
|
|
65
|
+
- Sources: <https://github.com/google-gemini/gemini-cli/blob/main/docs/cli/gemini-md.md>,
|
|
66
|
+
<https://github.com/google-gemini/gemini-cli/blob/main/docs/reference/memport.md>.
|
|
67
|
+
|
|
68
|
+
## Cursor
|
|
69
|
+
|
|
70
|
+
- Project rules: `.cursor/rules`, `.mdc` files only (a plain `.md` there is
|
|
71
|
+
ignored), with `description`, `globs` and `alwaysApply` frontmatter.
|
|
72
|
+
`alwaysApply: true` applies to every request; the others attach by file
|
|
73
|
+
match, agent choice or manual mention.
|
|
74
|
+
- `AGENTS.md` in the root and subdirectories; nested files combine with their
|
|
75
|
+
parents and the more specific one wins.
|
|
76
|
+
- `.cursorrules`: not mentioned in the current docs (`UNVERIFIED`); the probe
|
|
77
|
+
reports it as legacy.
|
|
78
|
+
- Limit: guidance to keep each rule under 500 lines.
|
|
79
|
+
- Readback: none documented.
|
|
80
|
+
- Sources: <https://cursor.com/docs/context/rules>,
|
|
81
|
+
<https://cursor.com/docs/cli/using>.
|
|
82
|
+
|
|
83
|
+
## GitHub Copilot
|
|
84
|
+
|
|
85
|
+
- `.github/copilot-instructions.md` (repository-wide) and
|
|
86
|
+
`.github/instructions/**/*.instructions.md` (path-specific, `applyTo`).
|
|
87
|
+
- Agent files: `AGENTS.md` (nearest wins) and a root `CLAUDE.md` or `GEMINI.md`.
|
|
88
|
+
- Support differs by surface: the cloud agent, the CLI and code review on
|
|
89
|
+
GitHub.com read all three kinds; VS Code chat reads `AGENTS.md` but not
|
|
90
|
+
`CLAUDE.md` or `GEMINI.md`; Copilot Chat on GitHub.com and code review in VS
|
|
91
|
+
Code and Visual Studio read only `copilot-instructions.md`.
|
|
92
|
+
- Limit: none documented; a character limit for code review is `UNVERIFIED`.
|
|
93
|
+
- Readback: none documented.
|
|
94
|
+
- Sources: <https://docs.github.com/en/copilot/reference/custom-instructions-support>,
|
|
95
|
+
<https://docs.github.com/en/copilot/how-tos/configure-custom-instructions/add-repository-instructions>,
|
|
96
|
+
<https://code.visualstudio.com/docs/copilot/customization/custom-instructions>.
|
|
97
|
+
|
|
98
|
+
## OpenCode
|
|
99
|
+
|
|
100
|
+
- Project: the first `AGENTS.md` found walking up from the working directory;
|
|
101
|
+
a `CLAUDE.md` only when no `AGENTS.md` exists. Global:
|
|
102
|
+
`~/.config/opencode/AGENTS.md`, else `~/.claude/CLAUDE.md`.
|
|
103
|
+
- `instructions` in the project `opencode.json`: extra paths, globs or URLs, all
|
|
104
|
+
added. Config files may be JSON or JSONC (JSON with comments). The docs name
|
|
105
|
+
`opencode.jsonc` for managed settings; a project `opencode.jsonc` is
|
|
106
|
+
`UNVERIFIED`, and the probe reads it too. The probe resolves local paths and
|
|
107
|
+
globs and never fetches URLs. Source: <https://opencode.ai/docs/config/>.
|
|
108
|
+
- Limit: none documented. Readback: none documented.
|
|
109
|
+
- Source: <https://opencode.ai/docs/rules>.
|
|
110
|
+
|
|
111
|
+
## Other clients
|
|
112
|
+
|
|
113
|
+
The `AGENTS.md` format page lists further clients that read `AGENTS.md`
|
|
114
|
+
(<https://agents.md>). Treat any client not covered above as `UNVERIFIED` until
|
|
115
|
+
its own documentation is checked.
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# One source of truth with pointers
|
|
2
|
+
|
|
3
|
+
Use this after an inventory, in `consolidate` or `repair` mode. Everything here
|
|
4
|
+
is project-local and needs a preview and explicit approval before any write.
|
|
5
|
+
|
|
6
|
+
## Choose the source of truth
|
|
7
|
+
|
|
8
|
+
1. Prefer the root `AGENTS.md`: Codex, Cursor, GitHub Copilot's agent surfaces,
|
|
9
|
+
OpenCode and Claude Code (directly or through an import) can read it.
|
|
10
|
+
2. If the project has no `AGENTS.md`, propose moving the shared content of the
|
|
11
|
+
largest current file into one, unless the team uses a single client and
|
|
12
|
+
says it does not want another file.
|
|
13
|
+
3. Keep nested `AGENTS.md` files that hold real directory-specific rules; Codex
|
|
14
|
+
and Cursor combine them with the root file, and closer files win.
|
|
15
|
+
|
|
16
|
+
## Reduce the other files
|
|
17
|
+
|
|
18
|
+
| File | Keep | Pointer |
|
|
19
|
+
|---|---|---|
|
|
20
|
+
| `CLAUDE.md` | Claude-only lines | `@AGENTS.md` on its own line |
|
|
21
|
+
| `.claude/CLAUDE.md` | Claude-only lines | `@../AGENTS.md`: imports resolve from the importing file's folder |
|
|
22
|
+
| `CLAUDE.local.md` | personal, uncommitted lines | none; it stops Claude reading `AGENTS.md` directly, so keep the `@AGENTS.md` import in `CLAUDE.md` |
|
|
23
|
+
| `GEMINI.md` | Gemini-only lines | `@AGENTS.md`, or `context.fileName` in project `.gemini/settings.json` listing `AGENTS.md` |
|
|
24
|
+
| `.cursor/rules/*.mdc` | path- or task-scoped rules | none needed; Cursor reads `AGENTS.md` |
|
|
25
|
+
| `.github/copilot-instructions.md` | lines for surfaces that ignore `AGENTS.md` (Copilot Chat on GitHub.com, code review in IDEs) | no import syntax; keep it short and non-contradictory |
|
|
26
|
+
| `.github/instructions/*.instructions.md` | path-scoped rules with `applyTo` | none |
|
|
27
|
+
| `opencode.json` / `opencode.jsonc` `instructions` | extra files OpenCode should add | entries only |
|
|
28
|
+
|
|
29
|
+
Rules:
|
|
30
|
+
|
|
31
|
+
- Keep each fact in one file. Delete a duplicate only after the source of truth
|
|
32
|
+
holds the same meaning; when the copies disagree, report the conflict and ask
|
|
33
|
+
which wording wins instead of picking one.
|
|
34
|
+
- An import is not free: Claude Code and Gemini CLI load imported files at
|
|
35
|
+
start, so the import moves bytes, it does not remove them.
|
|
36
|
+
- A sentence that only tells an agent to read another file is unreliable; use
|
|
37
|
+
the client's import or file-name setting when it has one.
|
|
38
|
+
- A byte-identical copy (for example `CLAUDE.md` copied from `AGENTS.md`)
|
|
39
|
+
drifts, and GitHub Copilot's agent surfaces read both copies. Replace it with
|
|
40
|
+
the pointer unless the project requires the copy; a test that enforces the
|
|
41
|
+
copy is such a reason.
|
|
42
|
+
- Preserve client-managed blocks (for example `NOBRAINER-TECH-FLOW` markers),
|
|
43
|
+
`AGENTS.override.md` precedence and every byte outside the lines you change.
|
|
44
|
+
A `NOBRAINER-CODEX-CONTEXT` block left by an earlier release is ordinary
|
|
45
|
+
project content: keep it, or fold its facts into the source of truth and
|
|
46
|
+
remove the markers in the same approved preview.
|
|
47
|
+
- Never create a file a client does not read and call it loaded. A Codex
|
|
48
|
+
fallback name works only when `project_doc_fallback_filenames` lists it;
|
|
49
|
+
changing that global setting is a separate owner action.
|
|
50
|
+
- Keep credentials, private machine paths, mutable runtime state, guessed
|
|
51
|
+
commands and model names out of instruction files.
|
|
52
|
+
|
|
53
|
+
## Preview
|
|
54
|
+
|
|
55
|
+
Show, for every file: the current bytes, the proposed bytes or a unified diff,
|
|
56
|
+
and what each client in use will load afterwards with byte counts. Name the
|
|
57
|
+
rollback: the exact preimage of each changed file (or a recoverable copy) and
|
|
58
|
+
the files to delete if new. Wait for an explicit yes.
|
|
59
|
+
|
|
60
|
+
## Apply and read back
|
|
61
|
+
|
|
62
|
+
Write only the approved files, then run the inventory again. The change is
|
|
63
|
+
`UPDATED` when the new inventory matches the preview: the same files per client,
|
|
64
|
+
no new truncation, no import of a missing file, fewer or equal repeated lines and
|
|
65
|
+
the goal pointer as proposed. Otherwise restore the preimages and report `BLOCKED` with the
|
|
66
|
+
difference.
|
|
@@ -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
|
|
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-
|
|
109
|
-
|
|
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-
|
|
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
|
|
|
@@ -1 +1 @@
|
|
|
1
|
-
2.
|
|
1
|
+
2.5.0
|
|
@@ -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.
|
|
34
|
-
the
|
|
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
|