nobrainer-tech-flow 2.4.0 → 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.
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/AGENTS.md +1 -0
  5. package/CLAUDE.md +1 -0
  6. package/README.md +9 -6
  7. package/assets/nobrainer-tech-logo.svg +1 -1
  8. package/assets/plugin-logo.png +0 -0
  9. package/docs/INSTALL.md +9 -6
  10. package/docs/MIGRATION_TO_FLOW.md +14 -0
  11. package/docs/RELEASING.md +24 -0
  12. package/docs/SKILL_CURATION.md +8 -2
  13. package/docs/releases/v2.4.1.md +63 -0
  14. package/docs/releases/v2.5.0.md +190 -0
  15. package/gemini-extension.json +1 -1
  16. package/package.json +1 -1
  17. package/plugin.json +1 -1
  18. package/scripts/install.py +13 -6
  19. package/scripts/install.sh +137 -16
  20. package/scripts/install_skills.py +34 -5
  21. package/scripts/recommend_flow_setup.py +126 -21
  22. package/scripts/validate_skills.py +2 -1
  23. package/skills/nobrainer-context/SKILL.md +113 -0
  24. package/skills/nobrainer-context/references/clients.md +115 -0
  25. package/skills/nobrainer-context/references/consolidate.md +66 -0
  26. package/skills/nobrainer-context/scripts/context_inventory.py +425 -0
  27. package/skills/nobrainer-skill-doctor/SKILL.md +4 -4
  28. package/skills/nobrainer-tech-flow/VERSION +1 -1
  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
@@ -107,6 +107,85 @@ def recorded_setting(client: str, home: Path, destination: object, target: objec
107
107
  return None
108
108
 
109
109
 
110
+ def recorded_skill(name: object) -> bool:
111
+ """A name a setup record may hold: a current module, or one a release retired."""
112
+
113
+ return isinstance(name, str) and (name in BY_SKILL or name in _sibling(INSTALL_SKILLS).RETIRED_LEGACY)
114
+
115
+
116
+ def owns_link(target: Path, name: str) -> bool:
117
+ """True when target is this checkout's link for name; a retired module's link dangles."""
118
+
119
+ if name in BY_SKILL:
120
+ return links_to(target, SKILLS_DIR / name)
121
+ return _sibling(INSTALL_SKILLS).is_exact_legacy_link(target, name)
122
+
123
+
124
+ def recorded_retired_args(record: dict | None) -> list[str]:
125
+ """install_skills.py flags for retired modules a setup record says this setup linked."""
126
+
127
+ names = (record or {}).get("created_skills", [])
128
+ return [arg for name in names if isinstance(name, str) and name not in BY_SKILL for arg in ("--recorded-retired", name)]
129
+
130
+
131
+ def claim_retired_links(destination: Path, record: dict | None) -> list[tuple[Path, Path, tuple]]:
132
+ """Move the retired links a record lists aside, verifying each moved entry.
133
+
134
+ The claim is install_skills.py's atomic claim-and-verify: an entry that changed after
135
+ the check is put back untouched. The claims are deleted only once the whole setup run
136
+ has succeeded, and put back on any failure before that.
137
+ """
138
+
139
+ skills = _sibling(INSTALL_SKILLS)
140
+ claims = []
141
+ try:
142
+ for name in (record or {}).get("created_skills", []):
143
+ if not isinstance(name, str) or name in BY_SKILL:
144
+ continue
145
+ target = destination / name
146
+ if not skills.is_exact_legacy_link(target, name):
147
+ continue
148
+ try:
149
+ claim, expected = skills.claim_legacy_link(target, name)
150
+ except skills.ClaimRestoreError:
151
+ raise
152
+ except RuntimeError as exc:
153
+ print(f"PRESERVED: {target} changed during the setup and is left as it is ({exc})")
154
+ continue
155
+ claims.append((target, claim, expected))
156
+ except (OSError, skills.ClaimRestoreError) as exc:
157
+ errors = restore_retired_links(claims)
158
+ if errors:
159
+ raise skills.ClaimRestoreError(f"{exc}; {'; '.join(errors)}") from exc
160
+ raise
161
+ return claims
162
+
163
+
164
+ def restore_retired_links(claims: list[tuple[Path, Path, tuple]]) -> list[str]:
165
+ """Put claimed retired links back; return what could not be restored."""
166
+
167
+ errors = []
168
+ for target, claim, expected in reversed(claims):
169
+ try:
170
+ # Moves the entry back without replacing a new one, and removes the private folder.
171
+ _sibling(INSTALL_SKILLS).restore_claim(target, claim, expected)
172
+ except (OSError, RuntimeError) as exc:
173
+ errors.append(f"retired link restore failed: {exc}")
174
+ return errors
175
+
176
+
177
+ def commit_retired_links(claims: list[tuple[Path, Path, tuple]]) -> None:
178
+ """Delete the claimed retired links once the setup run has succeeded."""
179
+
180
+ for target, claim, expected in claims:
181
+ if _sibling(INSTALL_SKILLS).entry_fingerprint(claim) == expected:
182
+ claim.unlink()
183
+ claim.parent.rmdir()
184
+ print(f"REMOVED_RETIRED: {target}")
185
+ else:
186
+ print(f"BACKUP_PRESERVED: the claimed retired link changed and is kept: {claim}")
187
+
188
+
110
189
  def links_to(target: Path, source: Path) -> bool:
111
190
  """True when target is a symlink resolving to source; loops and dangling links are not."""
112
191
 
@@ -144,7 +223,7 @@ ITEMS = (
144
223
  Item("14", "nobrainer-spec-driven-development", "Specifications", ("software-development", "product"), "define durable contracts for architectural changes"),
145
224
  Item("15", "nobrainer-rca", "Root-cause analysis", ("software-development", "operations"), "diagnose repeated or causally unclear failures"),
146
225
  Item("16", "nobrainer-team", "Capability-to-role design", ("software-development", "operations"), "assign available capabilities to bounded work"),
147
- Item("17", "nobrainer-codex-context", "Project context", ("software-development", "operations"), "inspect and maintain project instructions and context"),
226
+ Item("17", "nobrainer-context", "Project instructions", ("software-development", "operations"), "one tidy set of project instructions for every coding agent"),
148
227
  Item("18", "nobrainer-wiki", "Durable knowledge", ("research", "product", "operations"), "query and maintain a sourced project knowledge base"),
149
228
  )
150
229
  BY_ID = {item.id: item for item in ITEMS}
@@ -604,10 +683,10 @@ def run(command: list[str], hide: tuple[str, ...] = ()) -> tuple[int, str]:
604
683
 
605
684
  def remove_exact_created_links(destination: Path, names: list[str]) -> None:
606
685
  for name in names:
607
- if name not in BY_SKILL:
686
+ if not recorded_skill(name):
608
687
  continue
609
688
  target = destination / name
610
- if links_to(target, SKILLS_DIR / name):
689
+ if owns_link(target, name):
611
690
  target.unlink()
612
691
 
613
692
 
@@ -651,6 +730,8 @@ def recorded_directories(state: dict[str, object], record: Path) -> list[Path]:
651
730
  A path may be spelled with or without the links on the way, so both spellings count. A
652
731
  folder the setup made is listed as it really is, and a base spelled with ``..`` reaches
653
732
  it on the way without lying below it, so the real form of each folder on that way counts.
733
+ An entry may be listed once. Two entries may still lead to one folder: a link put in place
734
+ of one of them since the setup does that. The undo removes no folder of such a group.
654
735
  """
655
736
 
656
737
  raw = state.get("created_dirs", [])
@@ -675,9 +756,9 @@ def recorded_directories(state: dict[str, object], record: Path) -> list[Path]:
675
756
  or not any(form == place or form in place.parents for form in forms for place in places)
676
757
  ):
677
758
  raise ValueError(f"created_dirs names a folder this setup cannot have created: {item}")
678
- if forms[1] in seen:
759
+ if path in seen:
679
760
  raise ValueError("created_dirs must list each folder the setup created once")
680
- seen.add(forms[1])
761
+ seen.add(path)
681
762
  directories.append(path)
682
763
  return directories
683
764
 
@@ -698,18 +779,26 @@ def is_link(path: Path) -> bool:
698
779
  return bool(attributes & getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0))
699
780
 
700
781
 
701
- def remove_empty_directories(directories: list[Path]) -> tuple[list[Path], list[Path]]:
782
+ def remove_empty_directories(directories: list[Path]) -> tuple[list[Path], list[Path], list[Path]]:
702
783
  """Remove each of these folders that is still empty, innermost first.
703
784
 
704
- Returns the folders removed and the folders kept because a folder above them is a
705
- link now. The record lists each folder with the links on the way resolved, so such a
706
- link means the profile was rearranged since the setup, and removing the folder would
707
- act on another tree.
785
+ Returns the folders removed, the folders kept because a folder above them is a link
786
+ now, and the folders kept because another entry leads to the same folder. The record
787
+ lists each folder with the links on the way resolved, so such a link means the profile
788
+ was rearranged since the setup, and removing the folder would act on another tree. Two
789
+ entries that lead to one folder cannot both be what the setup made, and the record
790
+ cannot say which one is, so neither is removed.
708
791
  """
709
792
 
793
+ real = [physical_path(path) for path in directories]
794
+ shared = {path for path, form in zip(directories, real) if real.count(form) > 1}
710
795
  removed: list[Path] = []
711
796
  kept: list[Path] = []
797
+ kept_shared: list[Path] = []
712
798
  for path in sorted(directories, key=lambda item: len(item.parts), reverse=True):
799
+ if path in shared:
800
+ kept_shared.append(path)
801
+ continue
713
802
  try:
714
803
  if is_link(path) or not path.is_dir():
715
804
  continue
@@ -720,7 +809,7 @@ def remove_empty_directories(directories: list[Path]) -> tuple[list[Path], list[
720
809
  except OSError:
721
810
  continue
722
811
  removed.append(path)
723
- return removed, kept
812
+ return removed, kept, kept_shared
724
813
 
725
814
 
726
815
  def state_path(home: Path, override: Path | None, client: str) -> Path:
@@ -773,7 +862,7 @@ def read_state_file(path: Path) -> dict[str, object]:
773
862
  or not isinstance(loaded.get("destination"), str)
774
863
  or not isinstance(loaded.get("created_skills"), list)
775
864
  or not isinstance(loaded.get("profile"), dict)
776
- or any(not isinstance(name, str) or name not in BY_SKILL for name in loaded.get("created_skills", []))
865
+ or not all(recorded_skill(name) for name in loaded.get("created_skills", []))
777
866
  ):
778
867
  raise ValueError(f"refusing to overwrite unrecognized setup state: {path}")
779
868
  try:
@@ -987,7 +1076,7 @@ def rollback(args: argparse.Namespace, state_file: Path) -> int:
987
1076
  return 3
988
1077
  destination = Path(state["destination"])
989
1078
  created = state.get("created_skills", [])
990
- if not isinstance(created, list) or len(created) != len(set(created)) or any(name not in BY_SKILL for name in created):
1079
+ if not isinstance(created, list) or len(created) != len(set(created)) or not all(recorded_skill(name) for name in created):
991
1080
  print("ERROR: rollback state contains invalid skill ownership entries", file=sys.stderr)
992
1081
  return 3
993
1082
  try:
@@ -1056,7 +1145,7 @@ def rollback(args: argparse.Namespace, state_file: Path) -> int:
1056
1145
  for name in created:
1057
1146
  target = destination / name
1058
1147
  if target.is_symlink():
1059
- if not links_to(target, SKILLS_DIR / name):
1148
+ if not owns_link(target, name):
1060
1149
  print(f"PRESERVED: changed target {target}")
1061
1150
  return 3
1062
1151
  elif target.exists():
@@ -1076,8 +1165,7 @@ def rollback(args: argparse.Namespace, state_file: Path) -> int:
1076
1165
  removed: list[str] = []
1077
1166
  for name in created:
1078
1167
  target = destination / name
1079
- source = SKILLS_DIR / name
1080
- if links_to(target, source):
1168
+ if owns_link(target, name):
1081
1169
  target.unlink()
1082
1170
  removed.append(name)
1083
1171
  elif target.exists() or target.is_symlink():
@@ -1126,11 +1214,13 @@ def rollback(args: argparse.Namespace, state_file: Path) -> int:
1126
1214
  print(f"ROLLBACK_READBACK: removed={','.join(removed) or 'none'}; profile=preserved")
1127
1215
  return 3
1128
1216
  state_file.unlink()
1129
- removed_dirs, kept_dirs = remove_empty_directories(directories)
1217
+ removed_dirs, kept_dirs, shared_dirs = remove_empty_directories(directories)
1130
1218
  for directory in removed_dirs:
1131
1219
  print(f"REMOVED_DIR: {directory}")
1132
1220
  for directory in kept_dirs:
1133
1221
  print(f"PRESERVED: {directory}: a folder above it is a link now, so it is left as it is")
1222
+ for directory in shared_dirs:
1223
+ print(f"PRESERVED: {directory}: another folder in the record leads to the same folder, so it is left as it is")
1134
1224
  if "created_dirs" not in state:
1135
1225
  # A record from before the folder list still gets its skills folder removed when empty.
1136
1226
  # One with the list says which folders the setup made, so no other is touched.
@@ -1366,10 +1456,13 @@ def main(argv: list[str] | None = None) -> int:
1366
1456
  # The record's own folder counts: a home that does not exist yet is made for it alone
1367
1457
  # when a client variable puts the skills and the instructions elsewhere.
1368
1458
  new_directories = missing_directories(destination, profile_target.parent, state.parent)
1459
+ # They are recorded as they really are, links resolved, and a path the record cannot
1460
+ # hold stops the run here, before anything is written.
1461
+ require_utf8(*(physical_path(path) for path in new_directories))
1369
1462
  # Inspect every target before writing any of them. This keeps a later
1370
1463
  # conflict from leaving a partial selection installed.
1371
1464
  for name in install_names:
1372
- command = [sys.executable, str(ROOT / "scripts" / "install_skills.py"), "--client", args.client, "--dest", str(destination), "--skill", name]
1465
+ command = [sys.executable, str(ROOT / "scripts" / "install_skills.py"), "--client", args.client, "--dest", str(destination), "--skill", name, *recorded_retired_args(previous)]
1373
1466
  code, _ = run(command, hide=("NOTE: ",))
1374
1467
  if code:
1375
1468
  return code
@@ -1379,7 +1472,7 @@ def main(argv: list[str] | None = None) -> int:
1379
1472
  profile_preimage = read_file_preimage(profile_target)
1380
1473
  if args.apply:
1381
1474
  for name in install_names:
1382
- command = [sys.executable, str(ROOT / "scripts" / "install_skills.py"), "--client", args.client, "--dest", str(destination), "--skill", name, "--apply"]
1475
+ command = [sys.executable, str(ROOT / "scripts" / "install_skills.py"), "--client", args.client, "--dest", str(destination), "--skill", name, "--apply", *recorded_retired_args(previous)]
1383
1476
  code, _ = run(command, hide=("NOTE: ",))
1384
1477
  if code:
1385
1478
  remove_exact_created_links(destination, new_skills)
@@ -1413,7 +1506,9 @@ def main(argv: list[str] | None = None) -> int:
1413
1506
  "backup": recorded_backup,
1414
1507
  "written_sha256": hashlib.sha256(profile_target.read_bytes()).hexdigest(),
1415
1508
  }
1416
- created = sorted(set(previous.get("created_skills", [])) | {name for name in new_skills if (destination / name).is_symlink()})
1509
+ # A retired module's link is gone once retired; then the record drops its name.
1510
+ kept = {name for name in previous.get("created_skills", []) if name in BY_SKILL or owns_link(destination / name, name)}
1511
+ created = sorted(kept | {name for name in new_skills if (destination / name).is_symlink()})
1417
1512
  next_state = {"destination": str(destination), "created_skills": created, "profile": profile}
1418
1513
  # Folders an earlier run made stay in the record as recorded, links already resolved
1419
1514
  # then. Resolving them again would follow a link put in their place since, and hand the
@@ -1428,10 +1523,18 @@ def main(argv: list[str] | None = None) -> int:
1428
1523
  # a run makes a folder, so that its undo follows the rule it was made under.
1429
1524
  if created_dirs or not previous or "created_dirs" in previous:
1430
1525
  next_state["created_dirs"] = created_dirs
1526
+ # The retired links the record lists go last, after every other write, and come back
1527
+ # if the record cannot be saved.
1528
+ retired_claims = []
1431
1529
  try:
1530
+ retired_claims = claim_retired_links(destination, previous)
1531
+ retired_names = {target.name for target, _, _ in retired_claims}
1532
+ next_state["created_skills"] = [name for name in created if name not in retired_names]
1432
1533
  save_state(state, next_state)
1433
- except (OSError, TypeError, ValueError) as exc:
1534
+ except (OSError, RuntimeError, TypeError, ValueError) as exc:
1434
1535
  rollback_errors: list[str] = []
1536
+ if isinstance(exc, _sibling(INSTALL_SKILLS).ClaimRestoreError):
1537
+ rollback_errors.append(str(exc))
1435
1538
  if profile.get("target") and "APPLIED: " in profile_output:
1436
1539
  restore_preimage = profile_preimage
1437
1540
  if backup_match:
@@ -1468,6 +1571,7 @@ def main(argv: list[str] | None = None) -> int:
1468
1571
  remove_exact_created_links(destination, new_skills)
1469
1572
  except OSError as rollback_exc:
1470
1573
  rollback_errors.append(f"skill rollback failed: {rollback_exc}")
1574
+ rollback_errors.extend(restore_retired_links(retired_claims))
1471
1575
  remaining_links = [
1472
1576
  name
1473
1577
  for name in new_skills
@@ -1492,6 +1596,7 @@ def main(argv: list[str] | None = None) -> int:
1492
1596
  detail = f"{exc}; rollback incomplete: {'; '.join(rollback_errors)}" if rollback_errors else f"{exc}; applied changes were rolled back and read back"
1493
1597
  print(f"ERROR: setup state save failed: {detail}", file=sys.stderr)
1494
1598
  return 3 if rollback_errors else 2
1599
+ commit_retired_links(retired_claims)
1495
1600
  after = installed_ids(destination)
1496
1601
  actual_added = sorted(after - before)
1497
1602
  expected_added = sorted(BY_SKILL[name].id for name in new_skills)
@@ -18,7 +18,7 @@ LINK_RE = re.compile(r"\[[^\]]+\]\((?!https?://|#|mailto:)([^)]+)\)")
18
18
 
19
19
  SUITE = {
20
20
  "nobrainer-auto-fine-tune": "nb-auto-fine-tune",
21
- "nobrainer-codex-context": "nb-codex-context",
21
+ "nobrainer-context": "nb-context",
22
22
  "nobrainer-skill-doctor": "nb-skill-doctor",
23
23
  "nobrainer-tech-flow": "NBFlow",
24
24
  "nobrainer-team": "nb-team",
@@ -69,6 +69,7 @@ LEGACY = {
69
69
  "nb-write",
70
70
  "nobrainer-autopilot",
71
71
  "nobrainer-capture-lesson",
72
+ "nobrainer-codex-context",
72
73
  "nobrainer-continuous-improvement",
73
74
  "nobrainer-memory",
74
75
  "nobrainer-memory-memsearch",
@@ -0,0 +1,113 @@
1
+ ---
2
+ name: nobrainer-context
3
+ description: "Use when the owner says nb-context or asks to set up, audit or tidy one project's instructions for coding agents (AGENTS.md, CLAUDE.md, GEMINI.md, Cursor rules, Copilot instructions): find what each client loads and its size against documented limits, report duplicates and contradictions, then propose one source of truth with short pointers, applied only after approval and read back."
4
+ ---
5
+
6
+ # NoBrainer Context
7
+
8
+ Keep one tidy set of project instructions that every coding agent in use reads.
9
+ This module owns the project-instruction boundary of one repository: which files
10
+ each client loads, what they cost, where they repeat or disagree, and one
11
+ consolidation change. It does not own model selection, global client settings,
12
+ session transfer, goal tracking or the delivery workflow.
13
+
14
+ ## Select a mode
15
+
16
+ - `inspect` (default) - inventory and report. Do not write.
17
+ - `consolidate` - inspect, propose one source of truth with pointers, show the
18
+ exact preview, wait for explicit approval, apply project-local edits only,
19
+ then inspect again.
20
+ - `repair` - fix one known defect, such as a duplicate managed block, a dangling
21
+ import or a truncated Codex chain. Do not rewrite unrelated instructions.
22
+
23
+ A question gets `inspect`. Use `consolidate` only when the owner asks to tidy or
24
+ set up the project's instructions. Findings are evidence to report, not
25
+ permission to edit.
26
+
27
+ ## 1. Inventory what each client loads
28
+
29
+ Run the bundled read-only probe from the project with Python 3.11+, replacing
30
+ `<context-skill-dir>` with this module's directory:
31
+
32
+ ```bash
33
+ python3 <context-skill-dir>/scripts/context_inventory.py --json
34
+ ```
35
+
36
+ It lists, per client (Codex, Claude Code, Gemini CLI, Cursor, GitHub Copilot,
37
+ OpenCode), the files loaded at start, on demand or by import, with bytes, lines
38
+ and words; the Codex byte budget and truncation; files over documented line
39
+ guidance; byte-identical copies; lines repeated across files; imports of missing
40
+ files; settings or Codex configuration it cannot parse; and the Flow goal pointer
41
+ state. Instructions in folders below the current one are listed as on demand and
42
+ left out of the start-up totals. Add `--include-global` to include each client's
43
+ user-level file and, for Claude and Gemini, its recursive imports in the same
44
+ size, duplicate and goal-pointer checks. Claude's 200-line guidance applies only
45
+ to `CLAUDE.md` files discovered directly, not imported files, `AGENTS.md` fallbacks
46
+ or `.claude/rules/*.md` rules. Use `--client NAME` to narrow the report and
47
+ `--goal-file PATH` for an explicit goal file.
48
+ `scripts/codex_context.py --check --json` keeps the detailed Codex chain probe.
49
+
50
+ The probe applies the rules in [clients.md](references/clients.md). Only those
51
+ documented behaviours count; anything marked `UNVERIFIED` there is reported as
52
+ unknown, never as a limit. Ask which clients the team actually uses when the
53
+ files alone do not say, and drop the others from the report.
54
+
55
+ ## 2. Report duplicates and contradictions
56
+
57
+ Repeated lines come from the probe. Contradictions need reading: compare the
58
+ files a client loads together for different commands, package managers, test
59
+ gates, branch rules, languages or tone, and for a file that tells the agent to
60
+ read another file in words instead of importing it. Report each with exact
61
+ `file:line` pairs. This step is report-only.
62
+
63
+ ## 3. Propose one source of truth
64
+
65
+ Follow [consolidate.md](references/consolidate.md). The usual shape is
66
+ `AGENTS.md` as the source of truth, `CLAUDE.md` holding `@AGENTS.md` plus only
67
+ Claude-specific lines, and each other client's file reduced to a pointer or to
68
+ the lines that client alone needs. Show the full preview (new bytes or a diff
69
+ per file), what each client will load afterwards and the rollback, then wait
70
+ for explicit approval. Apply only inside the project. Never edit global
71
+ instruction files, `~/.codex/config.toml`, `~/.gemini/settings.json`, client
72
+ settings, credentials or plugin installs unless the owner asks for that
73
+ separate action.
74
+
75
+ ## 4. Read back
76
+
77
+ After a change, run the probe again and compare it with the preview. That is a
78
+ file-level readback. Mark `RUNTIME_READBACK: VERIFIED` only from a client's own
79
+ output that lists loaded files in a fresh session, as listed per client in
80
+ [clients.md](references/clients.md); otherwise `UNKNOWN`. A probe exit code or
81
+ an installed module does not prove a client followed the instructions.
82
+
83
+ ## 5. Goal pointer
84
+
85
+ When the project keeps a Flow goal file (`GOAL_ID:` block, see
86
+ [long-run state](../nobrainer-tech-flow/references/long-run-state.md)), check
87
+ that the instructions name the current one and no stale or missing goal path.
88
+ Propose the one-line pointer fix in the same preview. Goal content, status and
89
+ handoff stay with `nobrainer-tech-flow` and `nobrainer-sessions`.
90
+ If no active goals remain, the pointer state is `NONE`, including when completed
91
+ goals are still mentioned; a pointer to a missing file remains `STALE_POINTER`.
92
+
93
+ ## Closeout
94
+
95
+ ```text
96
+ CONTEXT: CURRENT | ATTENTION | UPDATED | BLOCKED
97
+ PROJECT_ROOT: <path or UNKNOWN>
98
+ CLIENTS: <client: loaded files and bytes, one per line>
99
+ LIMITS: <truncated or over-guidance files, or NONE>
100
+ DUPLICATES: <count and top file:line pairs, or NONE>
101
+ CONTRADICTIONS: <file:line pairs, or NONE>
102
+ SOURCE_OF_TRUTH: <file, or NOT_PROPOSED>
103
+ GOAL_POINTER: NONE | POINTED | NOT_POINTED | STALE_POINTER
104
+ PROJECT_WRITE: NONE | <files changed>
105
+ RUNTIME_READBACK: VERIFIED | UNKNOWN | UNSUPPORTED
106
+ OWNER_ACTION_REQUIRED: <one exact action or NONE>
107
+ ROLLBACK: <exact preimages or NOT_APPLICABLE>
108
+ ```
109
+
110
+ Route audits across many projects or skill portfolios to
111
+ `nobrainer-skill-doctor`, broader setup and delivery to `nobrainer-tech-flow`,
112
+ context transfer to `nobrainer-sessions` and release closeout to
113
+ `nobrainer-review`.
@@ -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.