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.
- package/.agents/plugins/marketplace.json +20 -0
- package/.claude-plugin/marketplace.json +18 -0
- package/.claude-plugin/plugin.json +21 -0
- package/.codex-plugin/plugin.json +47 -0
- package/.opencode/INSTALL.md +37 -0
- package/.opencode/plugins/nobrainer-tech-skills.js +66 -0
- package/.pi/extensions/nobrainer-tech-skills.js +64 -0
- package/AGENTS.md +204 -0
- package/CLAUDE.md +204 -0
- package/CONTRIBUTING.md +70 -0
- package/GEMINI.md +1 -0
- package/LICENSE +21 -0
- package/README.md +658 -2
- package/SECURITY.md +33 -0
- package/adapters/bootstrap.md +27 -0
- package/assets/nobrainer-tech-logo.svg +1 -0
- package/assets/nobrainer-workflow.svg +155 -0
- package/assets/plugin-logo.png +0 -0
- package/bin/nobrainer-tech-flow.mjs +48 -0
- package/docs/BOUNDED_RUNNER.md +60 -0
- package/docs/CHAT_PLUGINS.md +29 -0
- package/docs/COMPATIBILITY.md +371 -0
- package/docs/INSTALL.md +626 -0
- package/docs/MIGRATION_TO_FLOW.md +117 -0
- package/docs/NAMING.md +39 -0
- package/docs/PRIVACY.md +9 -0
- package/docs/SESSION_RESTART.md +70 -0
- package/docs/SKILL_CURATION.md +124 -0
- package/docs/TESTING.md +232 -0
- package/docs/TRY_IT.md +109 -0
- package/docs/releases/v1.0.0.md +81 -0
- package/docs/releases/v1.1.0.md +85 -0
- package/docs/releases/v1.10.0.md +34 -0
- package/docs/releases/v1.11.0-publication-readback.md +25 -0
- package/docs/releases/v1.11.0.md +42 -0
- package/docs/releases/v1.12.0.md +30 -0
- package/docs/releases/v1.13.0.md +46 -0
- package/docs/releases/v1.14.0.md +43 -0
- package/docs/releases/v1.14.1.md +21 -0
- package/docs/releases/v1.2.0.md +87 -0
- package/docs/releases/v1.2.1.md +92 -0
- package/docs/releases/v1.3.0-publication-readback.md +69 -0
- package/docs/releases/v1.3.0.md +66 -0
- package/docs/releases/v1.3.1-publication-readback.md +76 -0
- package/docs/releases/v1.3.1.md +59 -0
- package/docs/releases/v1.4.0-publication-readback.md +81 -0
- package/docs/releases/v1.4.0.md +43 -0
- package/docs/releases/v1.5.0-publication-readback.md +76 -0
- package/docs/releases/v1.5.0.md +54 -0
- package/docs/releases/v1.6.0.md +64 -0
- package/docs/releases/v1.6.1-publication-readback.md +53 -0
- package/docs/releases/v1.6.1.md +82 -0
- package/docs/releases/v1.7.0.md +42 -0
- package/docs/releases/v1.7.1-publication-readback.md +64 -0
- package/docs/releases/v1.7.1.md +49 -0
- package/docs/releases/v1.8.0.md +63 -0
- package/docs/releases/v1.8.1.md +32 -0
- package/docs/releases/v1.9.0.md +33 -0
- package/docs/releases/v2.0.0-candidate.md +28 -0
- package/docs/releases/v2.0.0.md +53 -0
- package/docs/releases/v2.0.1.md +135 -0
- package/docs/releases/v2.0.2.md +189 -0
- package/docs/releases/v2.1.0.md +64 -0
- package/docs/releases/v2.1.1.md +43 -0
- package/docs/releases/v2.2.0.md +64 -0
- package/docs/releases/v2.2.1.md +11 -0
- package/docs/releases/v2.3.0.md +97 -0
- package/docs/releases/v2.3.1.md +71 -0
- package/docs/releases/v2.3.2.md +242 -0
- package/docs/releases/v2.4.0.md +126 -0
- package/gemini-extension.json +6 -0
- package/hooks/claude-hooks.json +17 -0
- package/hooks/hooks-cursor.json +10 -0
- package/hooks/run-hook.cmd +36 -0
- package/hooks/session-start +38 -0
- package/package.json +69 -4
- package/plugin.json +51 -0
- package/scripts/build_plugin.py +50 -0
- package/scripts/check_flow_update.py +18 -0
- package/scripts/check_public_commit.py +86 -0
- package/scripts/fetch_public_refs.py +69 -0
- package/scripts/install.py +600 -0
- package/scripts/install.sh +1392 -0
- package/scripts/install_guards.py +279 -0
- package/scripts/install_personalization.py +527 -0
- package/scripts/install_skills.py +951 -0
- package/scripts/npm_install.py +85 -0
- package/scripts/recommend_flow_setup.py +1522 -0
- package/scripts/validate_skills.py +465 -0
- package/skills/nobrainer-auto-fine-tune/SKILL.md +79 -0
- package/skills/nobrainer-auto-fine-tune/references/capacity-audit.md +49 -0
- package/skills/nobrainer-autoimprove/SKILL.md +212 -0
- package/skills/nobrainer-autoimprove/scripts/count_words.py +22 -0
- package/skills/nobrainer-browser/SKILL.md +234 -0
- package/skills/nobrainer-browser/references/cdp-profile-restart.md +93 -0
- package/skills/nobrainer-build/SKILL.md +173 -0
- package/skills/nobrainer-codex-context/SKILL.md +111 -0
- package/skills/nobrainer-codex-context/scripts/codex_context.py +175 -0
- package/skills/nobrainer-decide/SKILL.md +198 -0
- package/skills/nobrainer-dispatcher/SKILL.md +256 -0
- package/skills/nobrainer-dispatcher/agents/openai.yaml +4 -0
- package/skills/nobrainer-rca/SKILL.md +163 -0
- package/skills/nobrainer-research/SKILL.md +131 -0
- package/skills/nobrainer-review/SKILL.md +195 -0
- package/skills/nobrainer-security/SKILL.md +166 -0
- package/skills/nobrainer-sessions/SKILL.md +223 -0
- package/skills/nobrainer-sessions/references/evidence-receipt.md +55 -0
- package/skills/nobrainer-sessions/references/protocol.md +337 -0
- package/skills/nobrainer-sessions/references/session-restart.md +255 -0
- package/skills/nobrainer-sessions/scripts/evidence_receipt.py +373 -0
- package/skills/nobrainer-sessions/scripts/restart_gate.py +140 -0
- package/skills/nobrainer-sessions/scripts/run_bounded.py +390 -0
- package/skills/nobrainer-sessions/scripts/session_title.py +43 -0
- package/skills/nobrainer-skill-doctor/SKILL.md +149 -0
- package/skills/nobrainer-skill-doctor/references/astra.md +19 -0
- package/skills/nobrainer-skill-doctor/scripts/audit.py +100 -0
- package/skills/nobrainer-skill-doctor/scripts/context_candidates.py +133 -0
- package/skills/nobrainer-spec-driven-development/SKILL.md +132 -0
- package/skills/nobrainer-spec-driven-development/references/spec-template.md +114 -0
- package/skills/nobrainer-team/SKILL.md +165 -0
- package/skills/nobrainer-team/references/team-plan.md +64 -0
- package/skills/nobrainer-tech-flow/SKILL.md +239 -0
- package/skills/nobrainer-tech-flow/VERSION +1 -0
- package/skills/nobrainer-tech-flow/references/autopilot.md +72 -0
- package/skills/nobrainer-tech-flow/references/codex-routing.md +21 -0
- package/skills/nobrainer-tech-flow/references/communication.md +108 -0
- package/skills/nobrainer-tech-flow/references/correction-hooks.md +110 -0
- package/skills/nobrainer-tech-flow/references/daily-update.md +28 -0
- package/skills/nobrainer-tech-flow/references/delivery.md +243 -0
- package/skills/nobrainer-tech-flow/references/idea-to-delivery.md +82 -0
- package/skills/nobrainer-tech-flow/references/long-run-state.md +209 -0
- package/skills/nobrainer-tech-flow/references/model-routing.md +83 -0
- package/skills/nobrainer-tech-flow/references/optional-decisions.md +83 -0
- package/skills/nobrainer-tech-flow/references/project-start.md +22 -0
- package/skills/nobrainer-tech-flow/references/routing.md +125 -0
- package/skills/nobrainer-tech-flow/references/setup.md +180 -0
- package/skills/nobrainer-tech-flow/references/worktrees.md +138 -0
- package/skills/nobrainer-tech-flow/scripts/check_flow_update.py +298 -0
- package/skills/nobrainer-tech-flow/scripts/cleanup_worktree.py +327 -0
- package/skills/nobrainer-tech-flow/scripts/decision_config.py +107 -0
- package/skills/nobrainer-tech-flow/scripts/flow_guard.py +252 -0
- package/skills/nobrainer-tech-flow/scripts/typed_decisions.py +210 -0
- package/skills/nobrainer-wiki/SKILL.md +164 -0
- package/skills/nobrainer-wiki/references/setup.md +88 -0
- package/skills/nobrainer-writing/SKILL.md +272 -0
- package/skills/nobrainer-writing/agents/openai.yaml +4 -0
- package/skills/nobrainer-writing/references/brief-artifacts.md +357 -0
- package/skills/nobrainer-writing/references/plain-technical-english.md +76 -0
- 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())
|