@open-tomato/rafa 0.16.0 → 0.18.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.
@@ -0,0 +1,272 @@
1
+ #!/usr/bin/env bun
2
+ // @bun
3
+
4
+ // src/tools/ts-symbols/cli.ts
5
+ import { existsSync } from "fs";
6
+ import { createRequire } from "module";
7
+ import * as path from "path";
8
+ var USAGE = `usage: ts-symbols <command> <target> [--json]
9
+ outline <file> exported + top-level symbols with kinds and lines
10
+ def <file>:<line>:<col> definition location of the symbol at position (1-based)
11
+ refs <file>:<line>:<col> references across the nearest tsconfig project
12
+ type <file>:<line>:<col> resolved type text at position (--full to expand)`;
13
+ var COMMANDS = new Set(["outline", "def", "refs", "type"]);
14
+ var NO_TYPESCRIPT_EXIT = 3;
15
+ function fail(msg) {
16
+ console.error(`ts-symbols: ${msg}`);
17
+ process.exit(1);
18
+ }
19
+ function noResults(msg) {
20
+ console.error(`ts-symbols: ${msg}`);
21
+ process.exit(2);
22
+ }
23
+ function readRequest(argv) {
24
+ const json = argv.includes("--json");
25
+ const full = argv.includes("--full");
26
+ const args = argv.filter((a) => !a.startsWith("--"));
27
+ const [command, rawTarget] = args;
28
+ if (!command || command === "help") {
29
+ console.log(USAGE);
30
+ process.exit(command ? 0 : 1);
31
+ }
32
+ if (!rawTarget)
33
+ fail(`missing target
34
+ ${USAGE}`);
35
+ if (!COMMANDS.has(command))
36
+ fail(`unknown command "${command}"
37
+ ${USAGE}`);
38
+ return { command, rawTarget, json, full };
39
+ }
40
+ function typescriptDirectory(dir) {
41
+ const candidate = path.join(dir, "node_modules", "typescript");
42
+ if (existsSync(path.join(candidate, "package.json")))
43
+ return candidate;
44
+ const parent = path.dirname(dir);
45
+ return parent === dir ? undefined : typescriptDirectory(parent);
46
+ }
47
+ function loadTypeScript(root) {
48
+ const directory = typescriptDirectory(root);
49
+ if (!directory) {
50
+ console.error(`ts-symbols: no typescript under ${root}: add it to the project`);
51
+ process.exit(NO_TYPESCRIPT_EXIT);
52
+ }
53
+ return createRequire(import.meta.url)(directory);
54
+ }
55
+ var request = readRequest(process.argv.slice(2));
56
+ var ts = loadTypeScript(process.cwd());
57
+ function parseTarget(raw) {
58
+ const m = /^(.+):(\d+):(\d+)$/.exec(raw);
59
+ if (!m || m[1] === undefined)
60
+ fail(`expected <file>:<line>:<col> (1-based), got "${raw}"`);
61
+ const file = path.resolve(m[1]);
62
+ if (!ts.sys.fileExists(file))
63
+ fail(`file not found: ${file}`);
64
+ return { file, line: Number(m[2]), col: Number(m[3]) };
65
+ }
66
+ function relPath(file) {
67
+ const rel = path.relative(process.cwd(), file);
68
+ return rel.startsWith("..") ? file : rel;
69
+ }
70
+ var LOOSE_OPTIONS = {
71
+ allowJs: true,
72
+ target: ts.ScriptTarget.ES2022,
73
+ module: ts.ModuleKind.ESNext,
74
+ moduleResolution: ts.ModuleResolutionKind.Bundler
75
+ };
76
+ function createProject(absFile, singleFile) {
77
+ let configPath;
78
+ let rootNames = [absFile];
79
+ let options = LOOSE_OPTIONS;
80
+ let projectDir = path.dirname(absFile);
81
+ if (!singleFile) {
82
+ configPath = ts.findConfigFile(path.dirname(absFile), ts.sys.fileExists, "tsconfig.json");
83
+ if (configPath) {
84
+ projectDir = path.dirname(configPath);
85
+ const jsonFile = ts.readJsonConfigFile(configPath, ts.sys.readFile);
86
+ const parsed = ts.parseJsonSourceFileConfigFileContent(jsonFile, ts.sys, projectDir);
87
+ options = parsed.options;
88
+ rootNames = parsed.fileNames.slice();
89
+ if (!rootNames.some((f) => path.resolve(f) === absFile))
90
+ rootNames.push(absFile);
91
+ }
92
+ }
93
+ const host = {
94
+ getScriptFileNames: () => rootNames,
95
+ getScriptVersion: () => "0",
96
+ getScriptSnapshot: (file) => {
97
+ const text = ts.sys.readFile(file);
98
+ return text === undefined ? undefined : ts.ScriptSnapshot.fromString(text);
99
+ },
100
+ getCurrentDirectory: () => projectDir,
101
+ getCompilationSettings: () => options,
102
+ getDefaultLibFileName: (opts) => ts.getDefaultLibFilePath(opts),
103
+ fileExists: ts.sys.fileExists,
104
+ readFile: ts.sys.readFile,
105
+ readDirectory: ts.sys.readDirectory,
106
+ directoryExists: ts.sys.directoryExists,
107
+ getDirectories: ts.sys.getDirectories
108
+ };
109
+ return { service: ts.createLanguageService(host, ts.createDocumentRegistry()), configPath };
110
+ }
111
+ function sourceFileOf(project, file) {
112
+ const program = project.service.getProgram();
113
+ const fromProgram = program?.getSourceFile(file);
114
+ if (fromProgram)
115
+ return fromProgram;
116
+ const text = ts.sys.readFile(file) ?? "";
117
+ return ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true);
118
+ }
119
+ function offsetOf(sf, target) {
120
+ try {
121
+ return sf.getPositionOfLineAndCharacter(target.line - 1, target.col - 1);
122
+ } catch {
123
+ return fail(`position ${target.line}:${target.col} is outside ${relPath(target.file)}`);
124
+ }
125
+ }
126
+ function locOf(project, file, start) {
127
+ const sf = sourceFileOf(project, file);
128
+ const lc = sf.getLineAndCharacterOfPosition(start);
129
+ const starts = sf.getLineStarts();
130
+ const end = lc.line + 1 < starts.length ? starts[lc.line + 1] : sf.text.length;
131
+ return {
132
+ file,
133
+ line: lc.line + 1,
134
+ col: lc.character + 1,
135
+ lineText: sf.text.slice(starts[lc.line], end).trim()
136
+ };
137
+ }
138
+ var CONTAINER_KINDS = new Set(["class", "interface", "enum", "module", "type"]);
139
+ function navToOutline(sf, node, depth) {
140
+ const span = node.nameSpan ?? node.spans[0];
141
+ if (!span)
142
+ fail(`no source span for symbol "${node.text}"`);
143
+ const lc = sf.getLineAndCharacterOfPosition(span.start);
144
+ const item = {
145
+ name: node.text,
146
+ kind: node.kind,
147
+ line: lc.line + 1,
148
+ col: lc.character + 1,
149
+ exported: node.kindModifiers.split(",").includes("export")
150
+ };
151
+ if (depth > 0 && node.childItems?.length && CONTAINER_KINDS.has(node.kind)) {
152
+ item.children = node.childItems.map((c) => navToOutline(sf, c, depth - 1)).sort((a, b) => a.line - b.line || a.col - b.col);
153
+ }
154
+ return item;
155
+ }
156
+ function printOutline(items, indent) {
157
+ for (const item of items) {
158
+ const marker = item.exported ? "export " : "";
159
+ console.log(`${item.line}: ${indent}${marker}${item.kind} ${item.name}`);
160
+ if (item.children)
161
+ printOutline(item.children, indent + " ");
162
+ }
163
+ }
164
+ function cmdOutline(rawFile, json) {
165
+ const absFile = path.resolve(rawFile);
166
+ if (!ts.sys.fileExists(absFile))
167
+ fail(`file not found: ${absFile}`);
168
+ const project = createProject(absFile, true);
169
+ const tree = project.service.getNavigationTree(absFile);
170
+ const sf = sourceFileOf(project, absFile);
171
+ const items = (tree.childItems ?? []).filter((c) => c.kind !== ts.ScriptElementKind.alias).map((c) => navToOutline(sf, c, 1)).sort((a, b) => a.line - b.line || a.col - b.col);
172
+ if (json) {
173
+ console.log(JSON.stringify({ file: relPath(absFile), symbols: items }, null, 2));
174
+ return;
175
+ }
176
+ if (items.length === 0)
177
+ noResults(`no top-level symbols in ${relPath(absFile)}`);
178
+ printOutline(items, "");
179
+ }
180
+ function cmdDef(target, json) {
181
+ const project = createProject(target.file, false);
182
+ const pos = offsetOf(sourceFileOf(project, target.file), target);
183
+ const defs = project.service.getDefinitionAtPosition(target.file, pos) ?? [];
184
+ if (defs.length === 0)
185
+ noResults(`no definition found at ${relPath(target.file)}:${target.line}:${target.col}`);
186
+ const rows = defs.map((d) => {
187
+ const loc = locOf(project, d.fileName, d.textSpan.start);
188
+ return { file: relPath(d.fileName), line: loc.line, col: loc.col, kind: d.kind, name: d.name, lineText: loc.lineText };
189
+ });
190
+ if (json) {
191
+ console.log(JSON.stringify(rows, null, 2));
192
+ return;
193
+ }
194
+ for (const r of rows)
195
+ console.log(`${r.file}:${r.line}:${r.col} ${r.kind} ${r.name} \u2014 ${r.lineText}`);
196
+ }
197
+ function cmdRefs(target, json) {
198
+ const project = createProject(target.file, false);
199
+ const pos = offsetOf(sourceFileOf(project, target.file), target);
200
+ const refs = project.service.getReferencesAtPosition(target.file, pos) ?? [];
201
+ if (refs.length === 0)
202
+ noResults(`no references found at ${relPath(target.file)}:${target.line}:${target.col}`);
203
+ const defSpans = new Set((project.service.getDefinitionAtPosition(target.file, pos) ?? []).map((d) => `${d.fileName}:${d.textSpan.start}`));
204
+ const rows = refs.map((r) => {
205
+ const loc = locOf(project, r.fileName, r.textSpan.start);
206
+ const role = defSpans.has(`${r.fileName}:${r.textSpan.start}`) ? "def" : r.isWriteAccess ? "write" : "use";
207
+ return { file: relPath(r.fileName), line: loc.line, col: loc.col, role, lineText: loc.lineText };
208
+ }).sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line || a.col - b.col);
209
+ if (json) {
210
+ console.log(JSON.stringify(rows, null, 2));
211
+ return;
212
+ }
213
+ for (const r of rows)
214
+ console.log(`${r.file}:${r.line}:${r.col} [${r.role}] ${r.lineText}`);
215
+ console.error(`ts-symbols: ${rows.length} reference(s)${project.configPath ? ` in project ${relPath(project.configPath)}` : " (no tsconfig found \u2014 searched target file and its imports only)"}`);
216
+ }
217
+ function tokenAt(sf, pos) {
218
+ function descend(node) {
219
+ for (const child of node.getChildren(sf)) {
220
+ if (pos >= child.getStart(sf) && pos < child.getEnd())
221
+ return descend(child);
222
+ }
223
+ return node;
224
+ }
225
+ return descend(sf);
226
+ }
227
+ function cmdType(target, json, full) {
228
+ const project = createProject(target.file, false);
229
+ const sf = sourceFileOf(project, target.file);
230
+ const pos = offsetOf(sf, target);
231
+ const info = project.service.getQuickInfoAtPosition(target.file, pos);
232
+ if (!info)
233
+ noResults(`no symbol at ${relPath(target.file)}:${target.line}:${target.col}`);
234
+ const declared = ts.displayPartsToString(info.displayParts);
235
+ const docs = ts.displayPartsToString(info.documentation ?? []);
236
+ let expanded;
237
+ if (full) {
238
+ const program = project.service.getProgram();
239
+ const checker = program?.getTypeChecker();
240
+ if (checker) {
241
+ const node = tokenAt(sf, pos);
242
+ const type = checker.getTypeAtLocation(node);
243
+ expanded = checker.typeToString(type, node, ts.TypeFormatFlags.NoTruncation | ts.TypeFormatFlags.InTypeAlias);
244
+ }
245
+ }
246
+ if (json) {
247
+ console.log(JSON.stringify({ kind: info.kind, declared, expanded, documentation: docs || undefined }, null, 2));
248
+ return;
249
+ }
250
+ console.log(declared);
251
+ if (expanded && expanded !== declared)
252
+ console.log(`expanded: ${expanded}`);
253
+ if (docs)
254
+ console.log(`doc: ${docs.split(`
255
+ `)[0]}`);
256
+ }
257
+ function main({ command, rawTarget, json, full }) {
258
+ switch (command) {
259
+ case "outline":
260
+ return cmdOutline(rawTarget, json);
261
+ case "def":
262
+ return cmdDef(parseTarget(rawTarget), json);
263
+ case "refs":
264
+ return cmdRefs(parseTarget(rawTarget), json);
265
+ case "type":
266
+ return cmdType(parseTarget(rawTarget), json, full);
267
+ default:
268
+ fail(`unknown command "${command}"
269
+ ${USAGE}`);
270
+ }
271
+ }
272
+ main(request);
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  name: dev-planner
3
3
  description: "Use when writing or parsing a PLAN-<stub>.md for the rafa loop — block syntax, task lines, and report format."
4
+ provenance: first-party
4
5
  prevents: "plan tasks the loop's parser silently skips because they are shaped wrong"
5
6
  signal: silent
6
7
  when_to_use: "You are generating a plan document for the rafa loop, or reading one the loop will parse. Prevents: plan tasks the loop's parser silently skips because they are shaped wrong"
@@ -346,11 +347,10 @@ The strip happens where the loop reads the next task, `findNextTask` in `src/uti
346
347
  prompt, the operator log and the commit message: a declaration is a
347
348
  planning annotation, never an instruction.
348
349
  * Pick the agent by the task's SHAPE — not its subject or its verb —
349
- from the `### Task shape to agent` table in `context/workflow.md`.
350
- Prefer a row whose third column names a tracked
351
- `.claude/agents/<name>.md` file, since a `user-level` row does not
352
- travel with a fresh clone. Where no row fits, declare the granular
353
- keys instead of an agent.
350
+ from the task routing table below in this prompt. Prefer a row whose
351
+ third column names a tracked `.claude/agents/<name>.md` file, since a
352
+ `user-level` row does not travel with a fresh clone. Where no row fits,
353
+ declare the granular keys instead of an agent.
354
354
 
355
355
  There is no default agent. A task with no declaration passes no routing flag and runs at the loop's defaults, as does one whose every value failed to parse.
356
356
 
@@ -439,14 +439,17 @@ The wrap-up session always receives `full` regardless of the configured mode.
439
439
  How the file is read:
440
440
  - An item is a line opening with `- [ ] `, continued by the indented lines directly below it that open no list item of their own. A ticked `- [x]` item and a `- [BLOCKED]` item are skipped, and nothing inside a fenced code block is read.
441
441
  - An item's tag is the `[auto]`, `[human]`, or `[start]` written right after its box. Without one it takes its section's tag: a heading carrying `[auto]` tags its section `auto`, one carrying `[human]` or naming `manual`, `human`, `sign-off` or `team` tags it `human`, and one carrying `[start]` tags it `start`. A heading's tag reaches the deeper headings under it and ends at the next heading of its own level or shallower. An item under no tagged heading is `human`.
442
- - An `auto` item's first backticked span is its probe. The item merges as a required prerequisite, the tier whose failure halts a run.
443
- - A `human` item, and an `auto` item with no backticked span, is a reminder: named to the operator, never probed, and never halting a run.
444
- - A `start` item's first backticked span is its probe. The item is probed on first dispatch only (skipped on resume), and failure halts a run on first dispatch.
442
+ - An `auto` item's probe is the backticked span that ends it, after its final `: `. The item merges as a required prerequisite, the tier whose failure halts a run.
443
+ - A `start` item's probe is read the same way. The item is probed on first dispatch only (skipped on resume), and failure halts a run on first dispatch.
444
+ - A `human` item is a reminder: named to the operator, never probed, and never halting a run.
445
+ - An `auto` or `start` item that does not end with `: ` and its command — the command first, text after it, or no span at all — is malformed: `rafa loop start` and `rafa doctor` refuse the plan, naming its line, and run nothing.
445
446
 
446
447
  Rules:
447
448
  - Only include prerequisites that are genuinely non-automatable (installed services, external credentials, manual env var setup).
448
- - Tag an item `auto` only when a shell command proves it, and write that command as the item's first backticked span, with nothing quoted before it. The command asks for no input and changes nothing.
449
- - Tag an item `start` only when a shell command proves it and it should only be checked on the first dispatch of a plan (not on resume). Write the command as the item's first backticked span, with nothing quoted before it.
449
+ - An `[auto]` item holds exactly one backticked span, and that span is its command. The preflight runs it as written, so put no package name, path or other command in backticks anywhere in the item.
450
+ - Tag an item `auto` only when a shell command proves it, and end the item with `: ` and that command. The command asks for no input and changes nothing.
451
+ - Tag an item `start` only when a shell command proves it and it should only be checked on the first dispatch of a plan (not on resume). End it with `: ` and the command, one span, as for `auto`.
452
+ - Make the command complete: it exits 0 when the prerequisite holds. To prove a tool is installed, probe `<tool> --version`, `<tool> --help` or `which <tool>` — never a bare subcommand such as `uvx check-jsonschema`, which exits non-zero for want of arguments.
450
453
  - Tag every step for after the plan merges `human`, as the heading above does, so no run ever executes one as a probe.
451
454
  - Do not duplicate steps already documented in the repo's contributor docs (README, CONTRIBUTING, and the like).
452
455
  - For example: do not mark `bun install` as a prerequisite if it is already documented as a required step for all development work.
@@ -0,0 +1,93 @@
1
+ ---
2
+ name: documentation
3
+ description: "Use when writing TSDoc, wiring TypeDoc, documenting a route with OpenAPI, or choosing between `.docs/` and `docs/`."
4
+ provenance: first-party
5
+ tags:
6
+ - tsdoc
7
+ - typedoc
8
+ - openapi
9
+ - swagger
10
+ - express
11
+ - mcp
12
+ - react
13
+ prevents: undocumented code and a docs command that wipes the tracked docs tree while exiting clean
14
+ signal: silent
15
+ when_to_use: "You are writing TSDoc, wiring documentation tooling, or deciding where a new document belongs. Prevents: undocumented code and a docs command that wipes the tracked docs tree while exiting clean"
16
+ stack:
17
+ - typescript
18
+ ---
19
+
20
+ > Scope: file paths in this document are relative to `packages/service/` (the `@ar/service` package), except `.claude/`, the plans directory, the specs directory, and `tools/`, which live at the umbrella repo root.
21
+
22
+ # Documentation Skill
23
+
24
+ This skill defines how to document code in this repo.
25
+ Two kinds of documentation are maintained:
26
+
27
+ | Kind | Tool | Output | When to generate |
28
+ | --- | --- | --- | --- |
29
+ | **Code (TypeDoc)** | `typedoc` | typedoc's default `out`, which resolves to the TRACKED `docs/` tree | `bun run docs:generate` |
30
+ | **API (Swagger)** | `@asteasolutions/zod-to-openapi` + `swagger-ui-express` | `openapi.json` + served behind `ctx.requireAuth` at `GET /docs/` | `bun run docs:openapi` |
31
+
32
+ > Both rows are INSTALLED in `@ar/service` — the OpenAPI half is no longer a
33
+ > convention to adopt on some later day, and [`rules/openapi.md`](rules/openapi.md)
34
+ > records what is there rather than a setup to perform. The two scripts are
35
+ > SIBLINGS sharing no output tree, which is worth stating because the pair of
36
+ > names suggests they do: `docs:openapi` writes the gitignored `.docs/swagger/`,
37
+ > while `docs:generate` is a bare `typedoc` carrying no `typedoc.json` and no
38
+ > `typedocOptions` key in either `package.json` or `tsconfig.json` — all three
39
+ > measured absent — so it takes typedoc's own default `out`, the TRACKED `docs/`
40
+ > tree beside it, with `cleanOutputDir` defaulting true. Running it therefore
41
+ > DELETES that tree before writing (measured once at typedoc 0.28.20: 14 tracked
42
+ > files removed, at exit 0, for a site documenting nothing).
43
+
44
+ Manual documentation (ADRs, design notes) lives in `docs/` and is never gitignored.
45
+ Auto-generated outputs go in `.docs/` (dotfolder) and are always gitignored.
46
+
47
+ ---
48
+
49
+ ## Rules index
50
+
51
+ | Rule file | When to use |
52
+ | --- | --- |
53
+ | [`rules/tsdoc.md`](rules/tsdoc.md) | Writing TSDoc comments on any TypeScript symbol |
54
+ | [`rules/openapi.md`](rules/openapi.md) | Documenting Express routes with OpenAPI/Swagger |
55
+ | [`rules/typedoc-setup.md`](rules/typedoc-setup.md) | Adding TypeDoc infra to the repo (or a project derived from it) |
56
+
57
+ ---
58
+
59
+ ## Workflow
60
+
61
+ 1. **Determine kind before touching**: identify whether documentation is manual or auto-generated before editing. Manual docs live in `docs/`; auto-generated outputs live in `.docs/`.
62
+ 2. **Manual docs**: include author and date in the file header. Never gitignore `docs/`.
63
+ 3. **Auto-generated docs**: outputs go in `.docs/` (dotfolder). If adding a new documentation tool, add its output folder to `.gitignore`.
64
+ 4. **Update in parallel**: update documentation before or in parallel with code changes — do not defer docs to a follow-up PR.
65
+
66
+ ---
67
+
68
+ ## Quick checklist
69
+
70
+ Before marking a documentation task done:
71
+
72
+ - [ ] Every exported function, class, and interface has a TSDoc block with `@param` and `@returns`.
73
+ - [ ] Every file has a `@packageDocumentation` comment (or is covered by a module-level JSDoc).
74
+ - [ ] Every Express router factory is documented with `@remarks` listing its HTTP endpoints.
75
+ - [ ] `typedoc.json`, `tsconfig.docs.json`, and a `docs:generate` script exist (see [`rules/typedoc-setup.md`](rules/typedoc-setup.md)).
76
+ - [ ] `openapi.ts` exists and every route is registered in it — `openapi-coverage.test.ts` is the gate that says so, and a route added without a binding-table entry reddens it.
77
+ - [ ] `.docs/` is already gitignored twice over and needs no new entry: once at the repo root (`.gitignore`) and again at the package (`packages/service/.gitignore`), both files tracked. Running `git check-ignore -v` on the generated `openapi.json` names the PACKAGE rule, the nearer file winning — take the source off that output rather than predicting it.
78
+ - [ ] `docs/` (manual ADRs) is **not** gitignored.
79
+ - [ ] `bun run docs:openapi` passes with 0 errors. Do NOT reach for `bun run docs:generate` as a verification: it exits 0 while WIPING the tracked `docs/` tree (see the note under the table above).
80
+
81
+ ---
82
+
83
+ ## Toolchain
84
+
85
+ ```text
86
+ typedoc → HTML docs from TSDoc comments → the TRACKED docs/ tree
87
+ @asteasolutions/zod-to-openapi → OpenAPI document from the Zod schemas the routes parse with
88
+ swagger-ui-express → serves that document behind ctx.requireAuth at GET /docs/
89
+ export-openapi.ts → writes openapi.json, via bun run docs:openapi
90
+ ```
91
+
92
+ Dependencies go in `devDependencies` where the toolchain is only ever a test's or a generator's — `@seriousme/openapi-schema-validator`, `@types/swagger-ui-express` and `typedoc` are all there.
93
+ BOTH OpenAPI runtime halves sit in `dependencies` instead, and not just `swagger-ui-express`: `src/index.ts` imports `generateOpenApiDocument` at boot to build the document it serves, so `@asteasolutions/zod-to-openapi` is a runtime dependency too.
@@ -0,0 +1,242 @@
1
+ ---
2
+ name: git-workflow
3
+ description: "Use when pushing a branch, opening or updating a PR, naming a branch, rebasing, or force-pushing after a rebase."
4
+ provenance: first-party
5
+ prevents: duplicate PRs and outbound PR bodies published with leaked references and wrong diff counts
6
+ signal: silent
7
+ when_to_use: "You are naming a branch, pushing it, opening or updating a pull request, or recovering from a rebase. Prevents: duplicate PRs and outbound PR bodies published with leaked references and wrong diff counts"
8
+ tags:
9
+ - git
10
+ - workflow
11
+ - javascript
12
+ - shell
13
+ stack:
14
+ - javascript
15
+ - shell
16
+ ---
17
+
18
+ # Git Workflow
19
+
20
+ ## Branch, commit, and PR conventions
21
+
22
+ **Branch names:** `<type>/<issue-number>-<slug>` when the work is tied to a
23
+ GitHub issue — e.g. `feat/42-user-auth` — and plain `<type>/<slug>` otherwise.
24
+ `<type>` is one of the conventional-commit types (`feat fix chore docs
25
+ refactor test perf ci spike`).
26
+
27
+ **Commits** follow conventional commits: `type(scope): subject`.
28
+
29
+ **PR bodies** link the issue with GitHub's closing keywords: `Closes #42` for
30
+ a PR that finishes the issue, or `Refs #42` for one that only partially
31
+ addresses it. This distinction is load-bearing: only `Closes` triggers
32
+ GitHub's auto-close on merge. Working without an issue is fine — such a PR
33
+ simply carries no issue link; do not invent one to satisfy the convention.
34
+
35
+ ### rafa project naming convention
36
+
37
+ For the rafa project specifically, issue numbers are prefixed with `rafa-` and
38
+ reflected throughout the spec and branch workflow:
39
+
40
+ - **Issue reference** (in prose): `#rafa-<n>` — e.g., `#rafa-20`
41
+ - **Spec file**: `.rafa/specs/rafa-<n>-<slug>.md` — e.g., `.rafa/specs/rafa-20-pr-commands.md`
42
+ - **Plan stub** (in prose): `rafa-<n>-<slug>` — e.g., `rafa-20-pr-commands`
43
+ - **Branch name**: `feat/rafa-<n>-<slug>` — e.g., `feat/rafa-20-pr-commands`
44
+ - **PR title**: `rafa-<n>: <title>` — e.g., `rafa-20: Add PR commands`
45
+ - **PR body closing line**: `Closes #<n>` — e.g., `Closes #20` (GitHub issue #20, not
46
+ `#rafa-20`; the prefix is a rafa naming convention, not a GitHub issue alias)
47
+
48
+ ## Pushing and opening PRs
49
+
50
+ When pushing changes and creating PRs:
51
+
52
+ 1. Always check if the branch has been pushed before. If not, use `git push -u` to set the upstream tracking branch on the first push. This ensures that future pushes and `gh pr create` commands work smoothly without needing to specify the remote/branch each time.
53
+ 2. If the branch already has an associated PR, push to whichever remote the branch is tracking.
54
+ 3. If the branch hasn't been pushed before, default to pushing to `origin`.
55
+
56
+ ## PR-body close-out discipline (measured, this repo)
57
+
58
+ A PR body is the one piece of prose here that is OUTBOUND, unreviewable by any
59
+ gate, and irreversible once `gh pr create` returns. Four checks, all cheap:
60
+
61
+ 1. **Sweep it for origin needles before the push.** `findForbiddenMatches(content,
62
+ path)` takes CONTENT, so one /tmp `.mjs` under bun can feed it the body file
63
+ alongside `git ls-files`, with a fragment-built planted control in the same run.
64
+ Re-run it immediately before `gh pr create` — a close-out sweep ordered ahead
65
+ of the tasks that finish the body swept a draft nobody will publish.
66
+ 2. **Pre-flight with a control.** `gh pr list --head <branch> --state all` returning
67
+ nothing is a zero-hit scan; bare `--state open`, or `gh pr view`, both miss a
68
+ CLOSED prior PR on the same head. Send a known-existing head through the
69
+ IDENTICAL command shape in the same call so the empty result is readable.
70
+ Without it, a broken filter is indistinguishable from "no PR yet" — and the
71
+ consequence is a duplicate PR. The control's PR must be CLOSED or MERGED, not
72
+ merely one that HAS a PR: `--state all` exists precisely because the open-only
73
+ form misses a non-open prior PR, so an OPEN control proves only that the
74
+ command can find something and says nothing about the half whose failure
75
+ produces the duplicate. Measured opening one PR, the subject printed `<none>`
76
+ beside `25 MERGED` and `11 CLOSED` from two known heads.
77
+ 3. **Re-read the REMOTE after creating.** The sweep proves the FILE is clean; only
78
+ `gh pr view <n> --json body -q .body` diffed against the swept file (normalise
79
+ CRLF and the trailing newline) proves the swept bytes are the published ones.
80
+ It is the only check that catches a `--body-file` pointed at a stale draft.
81
+ 4. **Hold the four numbers three-dot, against the REMOTE base.** GitHub computes
82
+ `additions`/`deletions`/`changedFiles` from the base branch as the REMOTE has
83
+ it, so the local side is
84
+ `git diff --numstat $(git merge-base origin/main HEAD)..HEAD` summed after a
85
+ fetch — never `main..HEAD` two-dot, which drifts the moment main moves, and
86
+ never local `main` without checking. PRINT the local and remote merge-bases
87
+ against each other rather than assuming they agree: measured at one tip, local
88
+ `main` was 62 commits BEHIND `origin/main` (an ancestor, not diverged) while
89
+ both merge-bases with HEAD still resolved to the SAME commit — a coincidence
90
+ that would not survive the local branch moving past the fork point, and a
91
+ diverged local `main` otherwise attributes somebody else's commits to this
92
+ PR's diff with every number still looking plausible. Sum `--numstat`, not
93
+ `--shortstat`, so a binary file shows as `-` instead of silently contributing
94
+ zero, and report the binary count beside the totals. Read the commits list's
95
+ LAST oid against `git rev-parse HEAD` beside the counts: a count can agree
96
+ while the head differs.
97
+ 5. **A CONFLICTING PR schedules NO workflow run at all**, so `gh pr checks <n>`
98
+ printing `no checks reported` is ambiguous between a run that has not
99
+ scheduled yet and one that never will. GitHub builds a `pull_request` run
100
+ against `refs/pull/<n>/merge`, which it cannot create for a PR that does not
101
+ merge cleanly — so the reading is a ref existence check, not a guess:
102
+ `git ls-remote origin 'refs/pull/<n>/*'` answers `head` alone where a
103
+ MERGEABLE control PR answers `head` AND `merge`. That ref check is the
104
+ WHOLE reading and `mergeable` is only a corroborator, because GitHub
105
+ computes mergeability lazily and `gh` does not poll for it: measured
106
+ opening one PR, the subject answered `CONFLICTING` while BOTH mergeable
107
+ control PRs answered `UNKNOWN` and still exposed `refs/pull/<n>/merge`.
108
+ So `UNKNOWN` beside a present merge ref is the HEALTHY shape, not a
109
+ second finding — never let it stand in for the ref check. Name the
110
+ conflict set with
111
+ `git merge-tree --write-tree --name-only origin/main HEAD`, which
112
+ changes no worktree state. Parse it carefully: its FIRST line is the
113
+ written TREE OID, so a probe reading the whole block as paths reports a
114
+ 40-hex string as a conflicting file. The name list is the CONFLICT set
115
+ and not the changed-file set (the narrative below it carries
116
+ `Auto-merging <path>` lines for files that merged cleanly), and exit 1
117
+ is the signal rather than an error. Exit 1 has TWO causes though, a real
118
+ conflict or a failed invocation, so a CLEAN exit 0 is only worth trusting
119
+ beside a liveness control: build a two-sided conflict from loose objects
120
+ in a scratch repository (`git hash-object -w`, `git mktree`,
121
+ `git commit-tree` — no ref moved, no working tree touched), read it
122
+ through the same command, and confirm it answers exit 1 with a `CONFLICT`
123
+ line before believing the subject's exit 0. Do NOT rebase to clear it as an
124
+ afterthought inside a PR task: every commit oid moves, and a body whose
125
+ suite figures were measured at the pre-rebase tip stops describing the
126
+ head it is attached to.
127
+
128
+ **Every suite figure in the body must come from a run at the PR's OWN head.** A
129
+ loop that commits per task makes all plausible sources disagree and each was
130
+ correct when written (measured 679, then 690, then 692 cases across one plan, no
131
+ regression). Read the `pretest` stamp in the same capture, so the run is
132
+ simultaneously the artifact-freshness check and the proof the tree is publishable.
133
+
134
+ **Where the branch is carried-in red**, the hosted gate confirms the body's red
135
+ claim and is simultaneously NOT evidence about its suite figures: `back.yml` runs
136
+ the type-check before the suite under `bash -e`, so the job halts and vitest never
137
+ runs. Report both halves, or a reader seeing a red `test` job assumes the suite
138
+ is what failed.
139
+
140
+ ## `gh pr create` branch detection
141
+
142
+ If `gh pr create` says `you must first push the current branch to a remote` even though `git push -u` succeeded, create the PR with an explicit head ref:
143
+
144
+ ```bash
145
+ gh pr create --head <owner>:<branch> ...
146
+ ```
147
+
148
+ ## `gh issue close --reason` takes two words
149
+
150
+ `--reason` accepts only `completed` and `not planned`, and `not planned` is
151
+ two words: written bare, `gh` parses `planned` as a positional argument and
152
+ the close fails. Quote it.
153
+
154
+ ```bash
155
+ gh issue close <n> --reason "not planned" --comment "<why>"
156
+ ```
157
+
158
+ ## GITHUB_TOKEN and workflow chaining
159
+
160
+ Actions performed using the default `GITHUB_TOKEN` (including labels added by `github-actions[bot]` via `actions/github-script`) do **not** trigger `pull_request_target` or other workflow events. This is a GitHub limitation to prevent infinite loops. If one workflow adds a label that should trigger another workflow (e.g., a labeling workflow adds `cc:rebase` to trigger a rebase workflow), the label-adding step must use a **PAT** or **GitHub App token** (e.g., a `PR_RW_GITHUB_TOKEN` secret) instead of `GITHUB_TOKEN`.
161
+
162
+ ## GitHub API calls with special characters
163
+
164
+ When using `gh api` to post comments or replies containing backticks, `$()`, or other shell metacharacters, security hooks may block the command. Instead of passing the body inline with `-f body="..."`, write a JSON file and use `--input`:
165
+
166
+ ```bash
167
+ # Write JSON body to a scratch file (use the Write tool, not echo/cat),
168
+ # somewhere untracked such as reply_body.json under a tmp/ directory
169
+ # {"body": "Your comment with `backticks` and special chars"}
170
+
171
+ gh api repos/${GH_ORG}/${GH_REPO_NAME}/pulls/123/comments/456/replies --input reply_body.json
172
+ ```
173
+
174
+ ## Force-pushing after rebase with changes ahead and behind head
175
+
176
+ When rebasing a PR branch that has both new commits in the PR and new commits in upstream/main (i.e., the branch is both ahead and behind), the rebase will create a new commit history that doesn't match the existing remote branch. In this case, you must force-push to update the PR branch with the rebased commits. Use `--force-with-lease` to avoid overwriting others' work, but if the branch is tracking a different remote (e.g., `origin` instead of `upstream`), and the solution is plain `--force` do not execute. The allowed force push command in this scenario is:
177
+
178
+
179
+ ```bash
180
+ git push --force-with-lease
181
+ ```
182
+
183
+ **Note:** Plain `--force` can overwrite others' remote commits. Inform the team when this scenario comes up, and `--force-with-lease` cannot work. In normal setups, always prefer `--force-with-lease`.
184
+
185
+ ## Rebase workflow and conflict resolution
186
+
187
+ ### Handling unstaged changes during rebase
188
+
189
+ If `git rebase` fails with "You have unstaged changes" (common with spurious `package-lock.json` changes):
190
+
191
+ ```bash
192
+ git stash push -m "Stashing changes before rebase"
193
+ git rebase origin/main
194
+ git stash pop
195
+ ```
196
+ The stashed changes will be automatically merged back after the rebase completes.
197
+
198
+ **Note:** **Always** use `git stash pop` to restore a single stash after the rebase, rather than `git stash apply`, to avoid accidentally applying stash leftovers or the same stash multiple times if you need to rebase again.
199
+
200
+ ## Rebasing with uncommitted changes
201
+
202
+ If you need to rebase but have uncommitted changes (e.g., package-lock.json from startup npm install):
203
+
204
+ 1. Stash changes: `git stash push -m "Stash changes before rebase"`
205
+ 2. Rebase: `git rebase origin/main` (resolve conflicts if needed)
206
+ 3. After rebase completes, review stashed changes: `git stash show -p`
207
+ 4. If stashed changes are spurious (e.g., package-lock.json peer markers when package.json conflicts were resolved during rebase), drop the stash: `git stash drop`
208
+ 5. Otherwise, pop stash: `git stash pop` and discard spurious changes: `git restore package-lock.json` (if package.json unchanged)
209
+
210
+ This prevents rebase conflicts from uncommitted changes while preserving any work in progress.
211
+
212
+
213
+ ### Conflict resolution tips
214
+
215
+ - **Modify/delete conflicts**: When a rebase shows `CONFLICT (modify/delete): <file> deleted in <commit> and modified in HEAD`, use `git rm <file>` (not `git add`) to resolve by confirming the deletion. Use `git add <file>` only when you want to keep the modified version instead.
216
+ - **Before rebasing:** If `bun install` modified `package-lock.json` (common in CI/local), discard changes with `git restore package-lock.json` to avoid "unstaged changes" errors
217
+ - When resolving import conflicts (e.g., `<<<<<<< HEAD` with different imports), keep **both** imports if both are valid and needed by the component
218
+ - If both sides of a conflict have valid imports/hooks, keep both and remove any duplicate constant redefinitions
219
+ - **Complementary additions**: When both sides added new sections at the end of a file (e.g., both added different documentation tips), keep both sections rather than choosing one — they're not truly conflicting, just different additions
220
+ - **Preserve variable declarations used in common code**: When one side of a conflict declares a variable (e.g., `const iframe = po.previewPanel.getPreviewIframeElement()`) that is referenced in non-conflicting code between or after conflict markers, keep the declaration even when adopting the other side's verification approach — the variable is needed regardless of which style you choose
221
+ - **React component wrapper conflicts**: When rebasing UI changes that conflict on wrapper div classes (e.g., `flex items-start space-x-2` vs `flex items-end gap-1`), keep the newer styling from the incoming commit but preserve any functional components (like dialogs or modals) that exist in HEAD but not in the incoming change
222
+ - **Refactoring conflicts**: When incoming commits refactor code (e.g., extracting inline logic into helper functions), and HEAD has new features in the same area, integrate HEAD's features into the new structure. Example: if incoming code moves streaming logic to `runSingleStreamPass()` and HEAD adds mid-turn compaction to the inline code, add compaction support to the new function rather than keeping the old inline version
223
+
224
+ ## Git orphan branch pitfalls
225
+
226
+ - **`git checkout -` does not work after `git checkout --orphan`** — git does not record the pre-orphan branch in a way that `-` can resolve. Always capture the current branch before switching: `PREV=$(git branch --show-current)`, then return explicitly with `git checkout "$PREV"`.
227
+ - **Orphan commits break pre-commit hooks that reference `HEAD^1`** — a hook that diffs or lints against the parent commit fails with `fatal: ambiguous argument 'HEAD^1'` on a root commit. Hooks that require a parent commit are incompatible with orphan branches.
228
+ - **`git checkout <branch>` after staging on a side branch discards untracked working-tree files** — when you commit untracked files on branch B and then `git checkout A`, git restores the working tree to branch A's HEAD, removing any files that were committed on B but never existed on A. This affects both orphan branches and regular side branches used as checkpoint mechanisms — all accumulated task output files vanish on checkout-back.
229
+
230
+ ## Shallow clone push limitations
231
+
232
+ - **"shallow update not allowed" when pushing to an empty remote** — a shallow clone can only push to a remote that already has the shallow boundary commit in its history. Pushing a tag or branch from `git clone --depth 1` to a bare repo with no objects fails. Fix: use the project's existing `origin` (which has full history), not a new empty remote. `git fetch --unshallow` makes the clone full but is expensive and defeats the depth-1 optimization.
233
+ - **All git refs in an ephemeral shallow clone are ephemeral** — if a cleanup step destroys the clone's directory, every pack object, tag, and branch goes with it. Refs must be pushed to the origin remote (which has the shallow boundary) before teardown if they need to survive.
234
+
235
+ ## Tracking Tasks
236
+
237
+ 1. **Commit often** with clear, descriptive messages following conventional commits format.
238
+ 2. **Do not** include AI attribution in commit messages.
239
+ 3. **Do not** create PRs without passing tests and lint checks — run all relevant tools first.
240
+ 4. Follow [Branch, commit, and PR conventions](#branch-commit-and-pr-conventions)
241
+ above for branch names and PR bodies — nothing automates it here, so write
242
+ the `Closes #n` / `Refs #n` line by hand when the work is tied to an issue.