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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/AGENTS.md +1 -0
- package/CLAUDE.md +1 -0
- package/README.md +9 -6
- package/assets/nobrainer-tech-logo.svg +1 -1
- package/assets/plugin-logo.png +0 -0
- package/docs/INSTALL.md +9 -6
- package/docs/MIGRATION_TO_FLOW.md +14 -0
- package/docs/RELEASING.md +24 -0
- package/docs/SKILL_CURATION.md +8 -2
- package/docs/releases/v2.4.1.md +63 -0
- 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 +137 -16
- package/scripts/install_skills.py +34 -5
- package/scripts/recommend_flow_setup.py +126 -21
- 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
|
@@ -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-
|
|
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
|
|
686
|
+
if not recorded_skill(name):
|
|
608
687
|
continue
|
|
609
688
|
target = destination / name
|
|
610
|
-
if
|
|
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
|
|
759
|
+
if path in seen:
|
|
679
760
|
raise ValueError("created_dirs must list each folder the setup created once")
|
|
680
|
-
seen.add(
|
|
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
|
|
705
|
-
|
|
706
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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-
|
|
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.
|