nobrainer-tech-flow 0.0.0-stage → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (149) hide show
  1. package/.agents/plugins/marketplace.json +20 -0
  2. package/.claude-plugin/marketplace.json +18 -0
  3. package/.claude-plugin/plugin.json +21 -0
  4. package/.codex-plugin/plugin.json +47 -0
  5. package/.opencode/INSTALL.md +37 -0
  6. package/.opencode/plugins/nobrainer-tech-skills.js +66 -0
  7. package/.pi/extensions/nobrainer-tech-skills.js +64 -0
  8. package/AGENTS.md +204 -0
  9. package/CLAUDE.md +204 -0
  10. package/CONTRIBUTING.md +70 -0
  11. package/GEMINI.md +1 -0
  12. package/LICENSE +21 -0
  13. package/README.md +658 -2
  14. package/SECURITY.md +33 -0
  15. package/adapters/bootstrap.md +27 -0
  16. package/assets/nobrainer-tech-logo.svg +1 -0
  17. package/assets/nobrainer-workflow.svg +155 -0
  18. package/assets/plugin-logo.png +0 -0
  19. package/bin/nobrainer-tech-flow.mjs +48 -0
  20. package/docs/BOUNDED_RUNNER.md +60 -0
  21. package/docs/CHAT_PLUGINS.md +29 -0
  22. package/docs/COMPATIBILITY.md +371 -0
  23. package/docs/INSTALL.md +626 -0
  24. package/docs/MIGRATION_TO_FLOW.md +117 -0
  25. package/docs/NAMING.md +39 -0
  26. package/docs/PRIVACY.md +9 -0
  27. package/docs/SESSION_RESTART.md +70 -0
  28. package/docs/SKILL_CURATION.md +124 -0
  29. package/docs/TESTING.md +232 -0
  30. package/docs/TRY_IT.md +109 -0
  31. package/docs/releases/v1.0.0.md +81 -0
  32. package/docs/releases/v1.1.0.md +85 -0
  33. package/docs/releases/v1.10.0.md +34 -0
  34. package/docs/releases/v1.11.0-publication-readback.md +25 -0
  35. package/docs/releases/v1.11.0.md +42 -0
  36. package/docs/releases/v1.12.0.md +30 -0
  37. package/docs/releases/v1.13.0.md +46 -0
  38. package/docs/releases/v1.14.0.md +43 -0
  39. package/docs/releases/v1.14.1.md +21 -0
  40. package/docs/releases/v1.2.0.md +87 -0
  41. package/docs/releases/v1.2.1.md +92 -0
  42. package/docs/releases/v1.3.0-publication-readback.md +69 -0
  43. package/docs/releases/v1.3.0.md +66 -0
  44. package/docs/releases/v1.3.1-publication-readback.md +76 -0
  45. package/docs/releases/v1.3.1.md +59 -0
  46. package/docs/releases/v1.4.0-publication-readback.md +81 -0
  47. package/docs/releases/v1.4.0.md +43 -0
  48. package/docs/releases/v1.5.0-publication-readback.md +76 -0
  49. package/docs/releases/v1.5.0.md +54 -0
  50. package/docs/releases/v1.6.0.md +64 -0
  51. package/docs/releases/v1.6.1-publication-readback.md +53 -0
  52. package/docs/releases/v1.6.1.md +82 -0
  53. package/docs/releases/v1.7.0.md +42 -0
  54. package/docs/releases/v1.7.1-publication-readback.md +64 -0
  55. package/docs/releases/v1.7.1.md +49 -0
  56. package/docs/releases/v1.8.0.md +63 -0
  57. package/docs/releases/v1.8.1.md +32 -0
  58. package/docs/releases/v1.9.0.md +33 -0
  59. package/docs/releases/v2.0.0-candidate.md +28 -0
  60. package/docs/releases/v2.0.0.md +53 -0
  61. package/docs/releases/v2.0.1.md +135 -0
  62. package/docs/releases/v2.0.2.md +189 -0
  63. package/docs/releases/v2.1.0.md +64 -0
  64. package/docs/releases/v2.1.1.md +43 -0
  65. package/docs/releases/v2.2.0.md +64 -0
  66. package/docs/releases/v2.2.1.md +11 -0
  67. package/docs/releases/v2.3.0.md +97 -0
  68. package/docs/releases/v2.3.1.md +71 -0
  69. package/docs/releases/v2.3.2.md +242 -0
  70. package/docs/releases/v2.4.0.md +126 -0
  71. package/gemini-extension.json +6 -0
  72. package/hooks/claude-hooks.json +17 -0
  73. package/hooks/hooks-cursor.json +10 -0
  74. package/hooks/run-hook.cmd +36 -0
  75. package/hooks/session-start +38 -0
  76. package/package.json +69 -4
  77. package/plugin.json +51 -0
  78. package/scripts/build_plugin.py +50 -0
  79. package/scripts/check_flow_update.py +18 -0
  80. package/scripts/check_public_commit.py +86 -0
  81. package/scripts/fetch_public_refs.py +69 -0
  82. package/scripts/install.py +600 -0
  83. package/scripts/install.sh +1392 -0
  84. package/scripts/install_guards.py +279 -0
  85. package/scripts/install_personalization.py +527 -0
  86. package/scripts/install_skills.py +951 -0
  87. package/scripts/npm_install.py +85 -0
  88. package/scripts/recommend_flow_setup.py +1522 -0
  89. package/scripts/validate_skills.py +465 -0
  90. package/skills/nobrainer-auto-fine-tune/SKILL.md +79 -0
  91. package/skills/nobrainer-auto-fine-tune/references/capacity-audit.md +49 -0
  92. package/skills/nobrainer-autoimprove/SKILL.md +212 -0
  93. package/skills/nobrainer-autoimprove/scripts/count_words.py +22 -0
  94. package/skills/nobrainer-browser/SKILL.md +234 -0
  95. package/skills/nobrainer-browser/references/cdp-profile-restart.md +93 -0
  96. package/skills/nobrainer-build/SKILL.md +173 -0
  97. package/skills/nobrainer-codex-context/SKILL.md +111 -0
  98. package/skills/nobrainer-codex-context/scripts/codex_context.py +175 -0
  99. package/skills/nobrainer-decide/SKILL.md +198 -0
  100. package/skills/nobrainer-dispatcher/SKILL.md +256 -0
  101. package/skills/nobrainer-dispatcher/agents/openai.yaml +4 -0
  102. package/skills/nobrainer-rca/SKILL.md +163 -0
  103. package/skills/nobrainer-research/SKILL.md +131 -0
  104. package/skills/nobrainer-review/SKILL.md +195 -0
  105. package/skills/nobrainer-security/SKILL.md +166 -0
  106. package/skills/nobrainer-sessions/SKILL.md +223 -0
  107. package/skills/nobrainer-sessions/references/evidence-receipt.md +55 -0
  108. package/skills/nobrainer-sessions/references/protocol.md +337 -0
  109. package/skills/nobrainer-sessions/references/session-restart.md +255 -0
  110. package/skills/nobrainer-sessions/scripts/evidence_receipt.py +373 -0
  111. package/skills/nobrainer-sessions/scripts/restart_gate.py +140 -0
  112. package/skills/nobrainer-sessions/scripts/run_bounded.py +390 -0
  113. package/skills/nobrainer-sessions/scripts/session_title.py +43 -0
  114. package/skills/nobrainer-skill-doctor/SKILL.md +149 -0
  115. package/skills/nobrainer-skill-doctor/references/astra.md +19 -0
  116. package/skills/nobrainer-skill-doctor/scripts/audit.py +100 -0
  117. package/skills/nobrainer-skill-doctor/scripts/context_candidates.py +133 -0
  118. package/skills/nobrainer-spec-driven-development/SKILL.md +132 -0
  119. package/skills/nobrainer-spec-driven-development/references/spec-template.md +114 -0
  120. package/skills/nobrainer-team/SKILL.md +165 -0
  121. package/skills/nobrainer-team/references/team-plan.md +64 -0
  122. package/skills/nobrainer-tech-flow/SKILL.md +239 -0
  123. package/skills/nobrainer-tech-flow/VERSION +1 -0
  124. package/skills/nobrainer-tech-flow/references/autopilot.md +72 -0
  125. package/skills/nobrainer-tech-flow/references/codex-routing.md +21 -0
  126. package/skills/nobrainer-tech-flow/references/communication.md +108 -0
  127. package/skills/nobrainer-tech-flow/references/correction-hooks.md +110 -0
  128. package/skills/nobrainer-tech-flow/references/daily-update.md +28 -0
  129. package/skills/nobrainer-tech-flow/references/delivery.md +243 -0
  130. package/skills/nobrainer-tech-flow/references/idea-to-delivery.md +82 -0
  131. package/skills/nobrainer-tech-flow/references/long-run-state.md +209 -0
  132. package/skills/nobrainer-tech-flow/references/model-routing.md +83 -0
  133. package/skills/nobrainer-tech-flow/references/optional-decisions.md +83 -0
  134. package/skills/nobrainer-tech-flow/references/project-start.md +22 -0
  135. package/skills/nobrainer-tech-flow/references/routing.md +125 -0
  136. package/skills/nobrainer-tech-flow/references/setup.md +180 -0
  137. package/skills/nobrainer-tech-flow/references/worktrees.md +138 -0
  138. package/skills/nobrainer-tech-flow/scripts/check_flow_update.py +298 -0
  139. package/skills/nobrainer-tech-flow/scripts/cleanup_worktree.py +327 -0
  140. package/skills/nobrainer-tech-flow/scripts/decision_config.py +107 -0
  141. package/skills/nobrainer-tech-flow/scripts/flow_guard.py +252 -0
  142. package/skills/nobrainer-tech-flow/scripts/typed_decisions.py +210 -0
  143. package/skills/nobrainer-wiki/SKILL.md +164 -0
  144. package/skills/nobrainer-wiki/references/setup.md +88 -0
  145. package/skills/nobrainer-writing/SKILL.md +272 -0
  146. package/skills/nobrainer-writing/agents/openai.yaml +4 -0
  147. package/skills/nobrainer-writing/references/brief-artifacts.md +357 -0
  148. package/skills/nobrainer-writing/references/plain-technical-english.md +76 -0
  149. package/skills/nobrainer-writing/references/research.md +86 -0
@@ -0,0 +1,527 @@
1
+ #!/usr/bin/env python3
2
+ """Install portable nobrainer-tech-flow instructions in a client profile.
3
+
4
+ The command is intentionally dry-run by default. It only writes a managed block
5
+ to a known global instruction file after an explicit ``--apply``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import difflib
12
+ import os
13
+ import re
14
+ import shutil
15
+ import stat
16
+ import sys
17
+ import tempfile
18
+ from collections.abc import Mapping
19
+ from dataclasses import dataclass
20
+ from datetime import datetime, timezone
21
+ from pathlib import Path
22
+
23
+
24
+ START = "<!-- NOBRAINER-TECH-FLOW:START -->"
25
+ END = "<!-- NOBRAINER-TECH-FLOW:END -->"
26
+ LEGACY_FLOW_INSTRUCTIONS = re.compile(
27
+ r"\bnobrainer-ultra\b|^\s*#{1,6}\s+NoBrainer(?:[ .]?Tech)?\s+Flow\b",
28
+ re.IGNORECASE | re.MULTILINE,
29
+ )
30
+ # The two lines that carry a standing authorization. --keep-options recognises a grant
31
+ # only as one of these exact lines, never as a phrase somewhere in the block, so free
32
+ # text in a preference cannot forge one.
33
+ AUTO_UPDATE_RULE = "- On the first `nobrainer-tech-flow` use each calendar day, check for a safe verified update when this client exposes a supported check. This setting grants standing authorization to apply a `nobrainer-tech-flow`-only update after verifying the canonical source/version, reviewing the exact changes, and making a recoverable backup. Never apply destructive, unrelated, or uncertain changes; ask the owner first. If the client is inactive or cannot check, do not claim a check occurred."
34
+ AUTO_SESSION_RULE = "- This setting grants standing authorization for session rotation only when the host supports creating a fresh successor, context or checkpoint evidence warrants rotation, and no write is in flight. Create the successor, verify exact takeover by ID/readback, and archive the old session only after that readback. Do not create recursive visible workers or claim a restart when unsupported."
35
+
36
+
37
+ def has_rule_line(text: str, rule: str) -> bool:
38
+ """True when ``rule`` is a whole line of ``text``.
39
+
40
+ Only ``\\n`` and ``\\r\\n`` end a line here. ``str.splitlines`` would also split on
41
+ U+2028, U+0085 and other separators, which a path or a preference could carry.
42
+ """
43
+
44
+ return re.search(r"(?m)^" + re.escape(rule) + r"\r?$", text) is not None
45
+
46
+
47
+ def earlier_grant(existing: str, rule: str, phrase: str, label: str, flag: str) -> bool:
48
+ """Whether the block already holds this exact grant line; says so when one was edited."""
49
+
50
+ if has_rule_line(existing, rule):
51
+ return True
52
+ if phrase in existing:
53
+ print(
54
+ f"NOTE: an earlier {label} authorization was not kept because its line no longer "
55
+ f"matches what this installer writes; pass {flag} to grant it again"
56
+ )
57
+ return False
58
+
59
+
60
+ def unsafe_line(value: str) -> bool:
61
+ """True when ``value`` cannot be one plain Markdown line (or could split into two)."""
62
+
63
+ return "`" in value or not value.isprintable()
64
+
65
+
66
+ def preference_problem(value: str) -> str | None:
67
+ if len(value) > 240 or "<" in value or ">" in value or unsafe_line(value):
68
+ return "preferences must be a single line of at most 240 safe characters"
69
+ return None
70
+
71
+
72
+ SAVED_WIKI = re.compile(r"^- Relevant project wiki: read `([^`\r\n]+)`", re.MULTILINE)
73
+ SAVED_PREFERENCE = re.compile(
74
+ r"^- Owner-approved setup preference: ([^\r\n]+)$", re.MULTILINE
75
+ )
76
+
77
+ def build_block(
78
+ wiki_root: Path | None,
79
+ auto_update: bool,
80
+ auto_session_restart: bool,
81
+ preferences: str | None = None,
82
+ ) -> str:
83
+ wiki_rule = (
84
+ f"- Relevant project wiki: read `{wiki_root / 'WIKI.md'}`, locate relevant `index.md` entries with a targeted search, then read only the relevant pages. Do not scan the whole wiki by default."
85
+ if wiki_root is not None
86
+ else "- At project start, discover whether a relevant wiki exists. If one exists, read its `WIKI.md`, locate relevant `index.md` entries with a targeted search, then read only the relevant pages. Ask before creating a wiki unless project rules or prior authorization already settle it."
87
+ )
88
+ update_rule = (
89
+ AUTO_UPDATE_RULE
90
+ if auto_update
91
+ else "- On the first `nobrainer-tech-flow` use each calendar day, check for available updates when this client exposes a supported check and notify the owner; do not apply them automatically. If the client is inactive or cannot check, do not claim a check occurred."
92
+ )
93
+ session_rule = (
94
+ AUTO_SESSION_RULE
95
+ if auto_session_restart
96
+ else "- Assess context and checkpoint at appropriate milestones. Recommend session rotation when it would help; do not restart or archive automatically. When rotation is authorized, use the supported lifecycle, verify exact successor takeover by ID/readback, and archive the old session only after that readback. Do not create recursive visible workers or claim a restart when unsupported."
97
+ )
98
+ preference_rule = (
99
+ f"- Owner-approved setup preference: {preferences.strip()}\n"
100
+ if preferences and preferences.strip()
101
+ else ""
102
+ )
103
+ return f"""<!-- NOBRAINER-TECH-FLOW:START -->
104
+ ## nobrainer-tech-flow
105
+
106
+ - Use the `nobrainer-tech-flow` module as the task entrypoint when this client supports skill invocation, by the exact name this client lists for it. In Codex that is `$nobrainer-tech-flow:nobrainer-tech-flow` when modules are listed under the plugin name, or `$nobrainer-tech-flow` when they are listed plainly. If unavailable, check the documented installation path and report that limitation honestly.
107
+ - On first setup, load `nobrainer-auto-fine-tune` for a read-only capability audit; preserve MAIN model and effort, and mark unverified runtime values `UNKNOWN`.
108
+ - Preserve the user's selected MAIN model and effort. Inspect the host's actual subagent models, capabilities, concurrency and quota; delegate every independent ready unit promptly and refill slots after audited completion. Keep coordination in MAIN. Never assume a model is available or silently substitute one.
109
+ - Split substantial work into bounded tasks with clear outputs, exclusive write scope, dependencies, and verification. Keep integration and acceptance in MAIN; avoid duplicate or filler tasks.
110
+ - Derive short-term goals from the user's long-term direction (LDD) and define observable completion criteria. At project start, inspect the existing structure and layers, then recommend a fitting approach.
111
+ {wiki_rule}
112
+ {session_rule}
113
+ {update_rule}
114
+ {preference_rule}- Use `nobrainer-writing` for material writing when available; make technical documentation concrete, source-backed, and technically verified. Preserve facts, use the user's language, and verify at the actual delivery layer.
115
+ <!-- NOBRAINER-TECH-FLOW:END -->"""
116
+
117
+
118
+ @dataclass(frozen=True)
119
+ class Client:
120
+ path: Path
121
+ display: str
122
+
123
+
124
+ # The variable each client reads to locate its own configuration directory.
125
+ CLIENT_CONFIG_VARIABLE = {
126
+ "claude": "CLAUDE_CONFIG_DIR",
127
+ "codex": "CODEX_HOME",
128
+ "opencode": "XDG_CONFIG_HOME",
129
+ }
130
+
131
+
132
+ def config_directory(environ: Mapping[str, str], name: str) -> str:
133
+ """The directory a client variable names, or "" when it is unset.
134
+
135
+ An empty CODEX_HOME or XDG_CONFIG_HOME counts as unset, as it does for Codex and
136
+ in the XDG specification. Any other value is used exactly as written, and one that
137
+ is not absolute is an error, including an empty CLAUDE_CONFIG_DIR: the client
138
+ resolves it against its own working directory, the script runs from the checkout,
139
+ and neither can know where the other means.
140
+ """
141
+
142
+ value = environ.get(name)
143
+ if value is None or (value == "" and name != "CLAUDE_CONFIG_DIR"):
144
+ return ""
145
+ if not Path(value).is_absolute():
146
+ raise ValueError(f"{name} must be an absolute path, not {value!r}")
147
+ return value
148
+
149
+
150
+ def known_client_path(
151
+ client: str, home: Path | None = None, environ: Mapping[str, str] | None = None
152
+ ) -> Client | None:
153
+ """Return a global instruction path only where the client defines one.
154
+
155
+ An explicit ``home`` selects the documented default locations under it. Without
156
+ one, the variable the client reads itself applies: CLAUDE_CONFIG_DIR for Claude
157
+ Code, CODEX_HOME for Codex and XDG_CONFIG_HOME for OpenCode. Variables of other
158
+ clients are none of this client's business, so they are never checked.
159
+ """
160
+
161
+ if client == "agents":
162
+ return None
163
+ if environ is None:
164
+ environ = os.environ if home is None else {}
165
+ home = Path.home() if home is None else home
166
+ variable = CLIENT_CONFIG_VARIABLE.get(client)
167
+ base = config_directory(environ, variable) if variable else ""
168
+ paths = {
169
+ "codex": (Path(base) if base else home / ".codex") / "AGENTS.md",
170
+ "claude": (Path(base) if base else home / ".claude") / "CLAUDE.md",
171
+ "opencode": (Path(base) if base else home / ".config") / "opencode" / "AGENTS.md",
172
+ "copilot": home / ".copilot" / "copilot-instructions.md",
173
+ }
174
+ return Client(paths[client], client)
175
+
176
+
177
+ def codex_override_problem(path: Path) -> str | None:
178
+ """Explain why Codex would never read a block written to its global AGENTS.md."""
179
+
180
+ override = path.with_name("AGENTS.override.md")
181
+ try:
182
+ if override.is_file() and override.read_bytes().strip():
183
+ return (
184
+ f"{override} takes precedence over {path.name} in Codex, so a block "
185
+ f"written to {path.name} would not load; move or empty the override, "
186
+ "or pass --path to target it deliberately"
187
+ )
188
+ except OSError:
189
+ return None
190
+ return None
191
+
192
+
193
+ def managed_block_status(content: str, block: str) -> tuple[str, str | None]:
194
+ """Return (status, updated_content), rejecting malformed/duplicated markers."""
195
+
196
+ starts = [match.start() for match in re.finditer(re.escape(START), content)]
197
+ ends = [match.start() for match in re.finditer(re.escape(END), content)]
198
+ if not starts and not ends:
199
+ if LEGACY_FLOW_INSTRUCTIONS.search(content):
200
+ raise ValueError(
201
+ "unmarked existing nobrainer-tech-flow instructions detected; "
202
+ "explicit migration is required before installing a managed block"
203
+ )
204
+ separator = "" if not content or content.endswith(("\n", "\r")) else "\n"
205
+ return "MISSING", content + separator + block
206
+ if len(starts) != 1 or len(ends) != 1 or starts[0] >= ends[0]:
207
+ raise ValueError("malformed or duplicated nobrainer-tech-flow managed markers")
208
+
209
+ block_start = starts[0]
210
+ block_end = ends[0] + len(END)
211
+ old_block = content[block_start:block_end]
212
+ if old_block == block:
213
+ return "UNCHANGED", content
214
+ return "UPDATE", content[:block_start] + block + content[block_end:]
215
+
216
+
217
+ def imports_codex_global(content: str, codex_path: Path, home: Path) -> bool:
218
+ """Whether a Claude file is nothing but an ``@`` import of the Codex global file.
219
+
220
+ Only that form counts. Whether an import elsewhere in a Markdown file is live
221
+ depends on how Claude Code parses the whole file (lists, quotes, fences, raw HTML,
222
+ link definitions, tabs), and an independent review found many ordinary files that
223
+ looked like imports and were not, which left Claude without the block. A second
224
+ copy of the block is harmless, so every other Claude file gets its own. Claude
225
+ Code ends an import path at a backslash, so only slash spellings can match.
226
+ """
227
+
228
+ spellings = {"@" + codex_path.as_posix()}
229
+ try:
230
+ spellings.add("@~/" + codex_path.relative_to(home).as_posix())
231
+ except ValueError:
232
+ pass
233
+ lines = [
234
+ line
235
+ for line in content.replace("\r\n", "\n").replace("\r", "\n").split("\n")
236
+ if line.strip(" \t")
237
+ ]
238
+ return len(lines) == 1 and lines[0].rstrip(" \t") in spellings
239
+
240
+
241
+ def has_managed_block(path: Path) -> bool:
242
+ """True when the file, read through any symlink, carries a complete block."""
243
+
244
+ try:
245
+ text = path.read_text(encoding="utf-8")
246
+ except (OSError, UnicodeDecodeError):
247
+ return False
248
+ return START in text and END in text
249
+
250
+
251
+ def existing_managed_block(path: Path) -> str | None:
252
+ """Return the managed block already in a regular target file, without markers."""
253
+
254
+ try:
255
+ metadata = path.lstat()
256
+ except FileNotFoundError:
257
+ return None
258
+ if not stat.S_ISREG(metadata.st_mode):
259
+ return None
260
+ try:
261
+ content = path.read_text(encoding="utf-8")
262
+ except UnicodeDecodeError as exc:
263
+ raise ValueError(f"target is not UTF-8 text: {path}") from exc
264
+ managed = re.search(
265
+ re.escape(START) + r"(.*?)" + re.escape(END), content, re.DOTALL
266
+ )
267
+ return managed.group(1) if managed else None
268
+
269
+
270
+ def inspect_target(path: Path, block: str) -> tuple[str, str, int | None]:
271
+ """Read the target without following links and prepare its managed block."""
272
+
273
+ try:
274
+ metadata = path.lstat()
275
+ except FileNotFoundError:
276
+ status, updated = managed_block_status("", block)
277
+ assert updated is not None
278
+ return status, updated, None
279
+ if stat.S_ISLNK(metadata.st_mode) or not stat.S_ISREG(metadata.st_mode):
280
+ raise ValueError(f"target must be a regular non-symlink file: {path}")
281
+ try:
282
+ with path.open("r", encoding="utf-8", newline="") as handle:
283
+ content = handle.read()
284
+ except UnicodeDecodeError as exc:
285
+ raise ValueError(f"target is not UTF-8 text: {path}") from exc
286
+ status, updated = managed_block_status(content, block)
287
+ return status, updated or content, stat.S_IMODE(metadata.st_mode)
288
+
289
+
290
+ def backup_path(path: Path) -> Path:
291
+ stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S%fZ")
292
+ candidate = path.with_name(f"{path.name}.bak.{stamp}")
293
+ index = 1
294
+ while candidate.exists():
295
+ candidate = path.with_name(f"{path.name}.bak.{stamp}.{index}")
296
+ index += 1
297
+ return candidate
298
+
299
+
300
+ def write_atomically(path: Path, content: str, mode: int | None) -> Path | None:
301
+ """Back up an existing target, then atomically replace it with preserved mode."""
302
+
303
+ path.parent.mkdir(parents=True, exist_ok=True)
304
+ backup: Path | None = None
305
+ if path.exists():
306
+ backup = backup_path(path)
307
+ # Copy bytes and mode before replacing; exclusive destination avoids clobbering.
308
+ source_stat = path.lstat()
309
+ if stat.S_ISLNK(source_stat.st_mode) or not stat.S_ISREG(source_stat.st_mode):
310
+ raise ValueError(f"target changed and is no longer a regular file: {path}")
311
+ with path.open("rb") as source, backup.open("xb") as destination:
312
+ shutil.copyfileobj(source, destination)
313
+ os.chmod(backup, stat.S_IMODE(source_stat.st_mode))
314
+
315
+ descriptor, temporary_name = tempfile.mkstemp(
316
+ prefix=f".{path.name}.", suffix=".tmp", dir=path.parent
317
+ )
318
+ temporary = Path(temporary_name)
319
+ try:
320
+ with os.fdopen(descriptor, "w", encoding="utf-8", newline="") as handle:
321
+ handle.write(content)
322
+ handle.flush()
323
+ os.fsync(handle.fileno())
324
+ if mode is not None:
325
+ os.chmod(temporary, mode)
326
+ os.replace(temporary, path)
327
+ except Exception:
328
+ temporary.unlink(missing_ok=True)
329
+ raise
330
+ return backup
331
+
332
+
333
+ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
334
+ parser = argparse.ArgumentParser(description=__doc__)
335
+ parser.add_argument(
336
+ "--client",
337
+ required=True,
338
+ choices=("codex", "claude", "opencode", "agents", "copilot"),
339
+ help="client whose global personalization file should be inspected",
340
+ )
341
+ parser.add_argument(
342
+ "--home",
343
+ type=Path,
344
+ help=(
345
+ "profile home used to resolve the documented default global instruction path; "
346
+ "when omitted, CLAUDE_CONFIG_DIR, CODEX_HOME and XDG_CONFIG_HOME are honoured"
347
+ ),
348
+ )
349
+ parser.add_argument(
350
+ "--path",
351
+ type=Path,
352
+ help="explicit instruction-file path (useful for isolated tests or custom profiles)",
353
+ )
354
+ parser.add_argument(
355
+ "--wiki-root",
356
+ type=Path,
357
+ help="project wiki root containing WIKI.md; its resolved path is added to the managed instructions",
358
+ )
359
+ parser.add_argument(
360
+ "--auto-update",
361
+ action="store_true",
362
+ help="authorize safe, verified nobrainer-tech-flow-only updates; default is check and notify",
363
+ )
364
+ parser.add_argument(
365
+ "--auto-session-restart",
366
+ action="store_true",
367
+ help="authorize evidence-gated session rotation; default is assess, checkpoint, and recommend",
368
+ )
369
+ parser.add_argument(
370
+ "--preferences",
371
+ help="short owner-approved setup preferences to include in the managed block",
372
+ )
373
+ parser.add_argument(
374
+ "--keep-options",
375
+ action="store_true",
376
+ help=(
377
+ "keep the auto-update, session-restart and wiki-root settings already in the "
378
+ "managed block; flags given on this run are added to them, never removed"
379
+ ),
380
+ )
381
+ parser.add_argument(
382
+ "--apply", action="store_true", help="write changes; without this flag, only preview"
383
+ )
384
+ return parser.parse_args(argv)
385
+
386
+
387
+ def main(argv: list[str] | None = None) -> int:
388
+ for stream in (sys.stdout, sys.stderr):
389
+ try:
390
+ # Piped output on Windows uses the ANSI code page, which cannot print every
391
+ # character of the instruction files this preview echoes.
392
+ stream.reconfigure(encoding="utf-8", errors="replace")
393
+ except (AttributeError, ValueError):
394
+ pass
395
+ args = parse_args(argv)
396
+ home = args.home.expanduser() if args.home is not None else None
397
+ try:
398
+ resolved = known_client_path(args.client, home)
399
+ except ValueError as exc:
400
+ print(f"ERROR: {exc}", file=sys.stderr)
401
+ return 3
402
+ if resolved is None and args.path is None:
403
+ print(
404
+ f"UNSUPPORTED: no single documented global instruction path is defined for {args.client}; use --path only when the owner supplies the exact target",
405
+ file=sys.stderr,
406
+ )
407
+ return 2
408
+
409
+ path = (args.path or resolved.path).expanduser()
410
+ try:
411
+ if args.client == "codex" and args.path is None:
412
+ problem = codex_override_problem(path)
413
+ if problem:
414
+ raise ValueError(problem)
415
+ wiki_root: Path | None = None
416
+ if args.wiki_root is not None:
417
+ # Persist the caller's canonical alias path, while normalizing relative
418
+ # segments. Path.resolve() would silently replace user-facing symlinks.
419
+ wiki_root = Path(os.path.abspath(args.wiki_root.expanduser()))
420
+ if unsafe_line(str(wiki_root)):
421
+ raise ValueError("wiki root path contains characters unsafe for Markdown")
422
+ if not (wiki_root / "WIKI.md").is_file():
423
+ raise ValueError(f"wiki root must contain WIKI.md: {wiki_root}")
424
+ if args.preferences and preference_problem(args.preferences):
425
+ raise ValueError(preference_problem(args.preferences))
426
+ existing = existing_managed_block(path)
427
+ preferences = args.preferences
428
+ if preferences is None and existing:
429
+ saved = SAVED_PREFERENCE.search(existing)
430
+ if saved:
431
+ preferences = saved.group(1)
432
+ auto_update = args.auto_update
433
+ auto_session_restart = args.auto_session_restart
434
+ if args.keep_options and existing:
435
+ kept: list[str] = []
436
+ if not auto_update and earlier_grant(
437
+ existing, AUTO_UPDATE_RULE, "grants standing authorization to apply", "auto-update", "--auto-update"
438
+ ):
439
+ auto_update = True
440
+ kept.append("auto-update")
441
+ if not auto_session_restart and earlier_grant(
442
+ existing,
443
+ AUTO_SESSION_RULE,
444
+ "grants standing authorization for session rotation",
445
+ "session-restart",
446
+ "--auto-session-restart",
447
+ ):
448
+ auto_session_restart = True
449
+ kept.append("session-restart")
450
+ saved_wiki = SAVED_WIKI.search(existing)
451
+ if wiki_root is None and saved_wiki:
452
+ candidate = Path(saved_wiki.group(1)).parent
453
+ if unsafe_line(str(candidate)):
454
+ print("NOTE: saved wiki root is not a plain path and was not kept")
455
+ elif (candidate / "WIKI.md").is_file():
456
+ wiki_root = candidate
457
+ kept.append("wiki-root")
458
+ else:
459
+ print(f"NOTE: saved wiki root no longer contains WIKI.md and was not kept: {candidate}")
460
+ if kept:
461
+ print(f"KEPT_OPTIONS: {', '.join(kept)}")
462
+ block = build_block(wiki_root, auto_update, auto_session_restart, preferences)
463
+ if args.client == "claude":
464
+ try:
465
+ codex_global = known_client_path("codex", home).path
466
+ except ValueError:
467
+ # Without a usable Codex path there is nothing to inherit from.
468
+ codex_global = None
469
+ try:
470
+ metadata = path.lstat()
471
+ except FileNotFoundError:
472
+ metadata = None
473
+ if codex_global is not None and metadata is not None and stat.S_ISREG(metadata.st_mode):
474
+ with path.open("r", encoding="utf-8", newline="") as handle:
475
+ imported = imports_codex_global(
476
+ handle.read(), codex_global, home or Path.home()
477
+ )
478
+ if imported and has_managed_block(codex_global):
479
+ print(f"INHERITS_CODEX: {path} imports {codex_global}; no duplicate block added")
480
+ return 0
481
+ if imported:
482
+ print(
483
+ f"NOTE: {path} imports {codex_global}, which has no nobrainer-tech-flow "
484
+ "block; the block is written here so the instructions are not lost"
485
+ )
486
+ status, updated, mode = inspect_target(path, block)
487
+ print(f"TARGET: {path}")
488
+ if status == "UNCHANGED":
489
+ print("UNCHANGED: managed block is current")
490
+ return 0
491
+ print(f"{status}: nobrainer-tech-flow personalization block")
492
+ if wiki_root is not None:
493
+ print(f"WIKI_ROOT: {wiki_root}")
494
+ print(f"AUTO_UPDATE: {'AUTHORIZED_SAFE_VERIFIED_ONLY' if auto_update else 'CHECK_AND_NOTIFY'}")
495
+ print(f"AUTO_SESSION_RESTART: {'AUTHORIZED_EVIDENCE_GATED' if auto_session_restart else 'ASSESS_CHECKPOINT_RECOMMEND'}")
496
+ if status == "UPDATE":
497
+ print("PRESERVED: content outside the managed block")
498
+ try:
499
+ old_content = path.read_text(encoding="utf-8")
500
+ except FileNotFoundError:
501
+ old_content = ""
502
+ diff = difflib.unified_diff(
503
+ old_content.splitlines(),
504
+ updated.splitlines(),
505
+ fromfile=f"{path} (current)",
506
+ tofile=f"{path} (planned)",
507
+ lineterm="",
508
+ )
509
+ diff_lines = list(diff)
510
+ if diff_lines:
511
+ print("DIFF:")
512
+ print("\n".join(diff_lines))
513
+ if not args.apply:
514
+ print("DRY_RUN: pass --apply to write; no files changed")
515
+ return 0
516
+ backup = write_atomically(path, updated, mode)
517
+ if backup:
518
+ print(f"BACKUP: {backup}")
519
+ print(f"APPLIED: {path}")
520
+ return 0
521
+ except (OSError, ValueError) as exc:
522
+ print(f"ERROR: {exc}", file=sys.stderr)
523
+ return 3
524
+
525
+
526
+ if __name__ == "__main__":
527
+ raise SystemExit(main())