@rtorcato/repo-tooling 3.16.2 → 3.17.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.
@@ -492,6 +492,20 @@ export async function checkClaudeSkills() {
492
492
  hint,
493
493
  };
494
494
  }
495
+ // A local fork is `ok` for the same reason a modified copied asset is (#448):
496
+ // it is somebody's deliberate work, so it is named once and never nagged as
497
+ // fixable — pointing at a `fix` that would refuse is worse than saying nothing.
498
+ if (status.contentState && status.contentState !== 'pristine') {
499
+ const why = status.contentState === 'modified'
500
+ ? `has local changes since ${status.installedVersion}`
501
+ : 'carries no content record, so a fork cannot be told from a stale copy';
502
+ return {
503
+ check,
504
+ status: 'ok',
505
+ detail: `${SHIPPED_SKILL} skill at ${status.file} ${why}; this package ships ${status.shippedVersion} and will not overwrite it`,
506
+ hint: `Diff it against the shipped copy, then run \`npx @rtorcato/repo-tooling fix claude-skills --force-skills\` to take the shipped version`,
507
+ };
508
+ }
495
509
  return {
496
510
  check,
497
511
  status: 'ok',
@@ -76,6 +76,23 @@ async function resolveInstallDir(explicit, assumeYes) {
76
76
  const trimmed = typeof answer === 'string' ? answer.trim() : '';
77
77
  return trimmed ? path.resolve(trimmed) : null;
78
78
  }
79
+ /**
80
+ * Why the install refused, and what to do about it. A bare "skipped" would be
81
+ * its own failure mode: the user still wants the update, and nothing on screen
82
+ * would say how to get it or what they would be giving up (#480).
83
+ */
84
+ function describeSkillFork(result) {
85
+ const target = result.viaSymlink ? `${result.file} → ${result.realFile}` : result.realFile;
86
+ const why = result.contentState === 'modified'
87
+ ? `its content has diverged from the ${result.installedVersion} release it was installed from`
88
+ : 'it carries no content record, so a local fork and a stale copy are indistinguishable';
89
+ return [
90
+ `skipped — ${SHIPPED_SKILL} was not overwritten with ${result.shippedVersion}: ${why}`,
91
+ ` ${target}`,
92
+ ` compare: diff "${result.realFile}" "${result.shippedFile}"`,
93
+ ' overwrite anyway: fix claude-skills --force-skills',
94
+ ];
95
+ }
79
96
  export const BASE_FIXERS = [
80
97
  {
81
98
  target: 'copied-assets',
@@ -321,15 +338,22 @@ export const BASE_FIXERS = [
321
338
  riskLevel: 'safe-add',
322
339
  explicitOnly: true,
323
340
  canFixDrift: true,
324
- async run({ skillsDir, assumeYes }) {
341
+ async run({ skillsDir, forceSkills, assumeYes }) {
325
342
  const dir = await resolveInstallDir(skillsDir, assumeYes);
326
343
  if (!dir)
327
344
  return { filesWritten: [] };
328
- const result = await installClaudeSkill(dir);
345
+ const result = await installClaudeSkill(dir, SHIPPED_SKILL, { force: forceSkills });
329
346
  if (result.status === 'declined-downgrade') {
330
347
  console.error(chalk.yellow(` skipped — ${result.file} is at ${result.installedVersion}, newer than the ${result.shippedVersion} this package ships`));
331
348
  return { filesWritten: [] };
332
349
  }
350
+ if (result.status === 'declined-fork') {
351
+ // Name `realFile`: through a stow symlink the overwrite would land in a
352
+ // *second* repo's working tree, and that is the path to look at (#480).
353
+ for (const line of describeSkillFork(result))
354
+ console.error(chalk.yellow(` ${line}`));
355
+ return { filesWritten: [] };
356
+ }
333
357
  if (result.status === 'up-to-date')
334
358
  return { filesWritten: [] };
335
359
  // Report the resolved real path when the skill is a stow symlink: the bytes
@@ -413,6 +413,7 @@ export async function fixCommand(target, options = {}) {
413
413
  // branch and records a skip, since one refusal must not abandon the rest.
414
414
  const outcome = await applyFixer(fixer, effectiveResult, targetDir, pkg, lock, dryRun, silent, {
415
415
  skillsDir: options.skillsDir,
416
+ forceSkills: options.forceSkills,
416
417
  assumeYes,
417
418
  }).catch((err) => {
418
419
  if (!(err instanceof FixerAbort))
@@ -488,6 +489,7 @@ export async function fixCommand(target, options = {}) {
488
489
  try {
489
490
  outcome = await applyFixer(fixer, result, targetDir, pkg, lock, dryRun, silent, {
490
491
  skillsDir: options.skillsDir,
492
+ forceSkills: options.forceSkills,
491
493
  assumeYes,
492
494
  });
493
495
  }
@@ -108,7 +108,7 @@ export async function setupProject(options) {
108
108
  const interactive = !options.config && !options.preset;
109
109
  const dryRun = options.dryRun === true;
110
110
  if (interactive && !dryRun) {
111
- console.log(chalk.cyan('\n🛠️ Welcome to JS Tooling Setup!\n'));
111
+ console.log(chalk.cyan('\n🛠️ Welcome to repo-tooling setup!\n'));
112
112
  console.log(chalk.gray(`Setting up tooling in: ${targetDir}\n`));
113
113
  }
114
114
  try {
@@ -4,6 +4,7 @@
4
4
  * project on the machine. That difference drives all three rules below — the
5
5
  * version stamp, the symlink handling, and the fixer's opt-in `explicitOnly`.
6
6
  */
7
+ import { createHash } from 'node:crypto';
7
8
  import os from 'node:os';
8
9
  import path from 'node:path';
9
10
  import fs from 'fs-extra';
@@ -17,6 +18,19 @@ export const SHIPPED_SKILL = 'ai-issue-loop';
17
18
  * is wrong to do so.
18
19
  */
19
20
  export const VERSION_KEY = 'repo-tooling-version';
21
+ /**
22
+ * The pristine sha256 of the content we wrote, stamped beside the version — the
23
+ * skills half of #448. The version alone cannot tell a stale copy from a
24
+ * deliberate local fork: a fork that is merely older than the package looks
25
+ * exactly like a copy waiting for an update, and gets overwritten (#480).
26
+ * With the hash, "installed content still matches what some release of this
27
+ * package shipped" is a fact rather than an inference.
28
+ *
29
+ * It lives in the file instead of `.repo-tooling.json` because skills are
30
+ * user-global — no one repo owns the record.
31
+ */
32
+ export const HASH_KEY = 'repo-tooling-hash';
33
+ const STAMP_KEYS = [VERSION_KEY, HASH_KEY];
20
34
  const FRONTMATTER = /^---\n([\s\S]*?)\n---\n/;
21
35
  /**
22
36
  * Where to install. `explicit` is `--skills-dir`; otherwise the user-level
@@ -35,25 +49,64 @@ export async function resolveSkillsDir(explicit, home = os.homedir()) {
35
49
  return { dir: userDir, source: 'user' };
36
50
  return { dir: null, source: 'none' };
37
51
  }
52
+ function readStamp(content, key) {
53
+ return content.match(new RegExp(`^${key}:\\s*(.+)$`, 'm'))?.[1]?.trim() ?? null;
54
+ }
38
55
  /** The version recorded in an installed copy, or null if it predates the stamp. */
39
56
  export function readSkillVersion(content) {
40
- return content.match(new RegExp(`^${VERSION_KEY}:\\s*(.+)$`, 'm'))?.[1]?.trim() ?? null;
57
+ return readStamp(content, VERSION_KEY);
58
+ }
59
+ /** The pristine hash recorded in an installed copy, or null if it predates it. */
60
+ export function readSkillHash(content) {
61
+ return readStamp(content, HASH_KEY);
41
62
  }
42
63
  /**
43
- * Replace (or add) the version line in the frontmatter. Appending it last is
64
+ * Replace (or add) the stamp lines in the frontmatter. Appending them last is
44
65
  * safe even after a multi-line `description: |` block: an unindented key ends
45
- * the block scalar, which is exactly what this line is.
66
+ * the block scalar, which is exactly what these lines are.
67
+ *
68
+ * With no stamps this is the exact inverse of stamping, so a file we wrote
69
+ * strips back to the bytes we were given — which is what makes the hash
70
+ * comparable.
46
71
  */
47
- export function stampSkillVersion(content, version) {
48
- const stamp = `${VERSION_KEY}: ${version}`;
72
+ function setStamps(content, stamps) {
49
73
  const match = content.match(FRONTMATTER);
50
74
  if (!match)
51
- return `---\n${stamp}\n---\n\n${content}`;
52
- const fields = (match[1] ?? '')
75
+ return stamps.length === 0 ? content : `---\n${stamps.join('\n')}\n---\n\n${content}`;
76
+ const kept = (match[1] ?? '')
53
77
  .split('\n')
54
- .filter((line) => !line.startsWith(`${VERSION_KEY}:`))
55
- .join('\n');
56
- return `---\n${fields}\n${stamp}\n---\n${content.slice(match[0].length)}`;
78
+ .filter((line) => !STAMP_KEYS.some((key) => line.startsWith(`${key}:`)));
79
+ return `---\n${[...kept, ...stamps].join('\n')}\n---\n${content.slice(match[0].length)}`;
80
+ }
81
+ /** An installed copy with this package's own bookkeeping lines removed. */
82
+ export function stripSkillStamps(content) {
83
+ return setStamps(content, []);
84
+ }
85
+ /**
86
+ * The version stamp on its own — the shape releases before #480 wrote, and what
87
+ * `classifySkillContent` sees as `unknown`. Not the write path; `stampSkill` is.
88
+ */
89
+ export function stampSkillVersion(content, version) {
90
+ return setStamps(content, [`${VERSION_KEY}: ${version}`]);
91
+ }
92
+ /** sha256 of the content this package shipped, ignoring the stamps it adds. */
93
+ export function hashSkillContent(content) {
94
+ return createHash('sha256').update(stripSkillStamps(content)).digest('hex');
95
+ }
96
+ /** The version + hash stamps, as written to disk. */
97
+ export function stampSkill(content, version) {
98
+ return setStamps(content, [
99
+ `${VERSION_KEY}: ${version}`,
100
+ `${HASH_KEY}: ${hashSkillContent(content)}`,
101
+ ]);
102
+ }
103
+ export function classifySkillContent(installed, shipped) {
104
+ if (stripSkillStamps(installed) === stripSkillStamps(shipped))
105
+ return 'pristine';
106
+ const recorded = readSkillHash(installed);
107
+ if (!recorded)
108
+ return 'unknown';
109
+ return recorded === hashSkillContent(installed) ? 'pristine' : 'modified';
57
110
  }
58
111
  function versionParts(version) {
59
112
  return version.split('.').map((n) => Number.parseInt(n, 10) || 0);
@@ -71,9 +124,10 @@ export function isNewerVersion(a, b) {
71
124
  /** The skill source and the package version that will be stamped into it. */
72
125
  export async function readShippedSkill(name = SHIPPED_SKILL) {
73
126
  const root = getPackageRoot();
74
- const content = await fs.readFile(path.join(root, 'skills', name, 'SKILL.md'), 'utf8');
127
+ const file = path.join(root, 'skills', name, 'SKILL.md');
128
+ const content = await fs.readFile(file, 'utf8');
75
129
  const pkg = await fs.readJson(path.join(root, 'package.json'));
76
- return { content, version: String(pkg.version) };
130
+ return { content, version: String(pkg.version), file };
77
131
  }
78
132
  /** Whether `file` is itself a symlink, as opposed to merely resolving through one. */
79
133
  async function isSymlink(file) {
@@ -94,7 +148,7 @@ async function isSymlink(file) {
94
148
  * rename *replaces* the symlink with a real file, orphaning the dotfiles copy
95
149
  * with no error at all, which is the split-brain this feature exists to end.
96
150
  */
97
- export async function installClaudeSkill(skillsDir, name = SHIPPED_SKILL) {
151
+ export async function installClaudeSkill(skillsDir, name = SHIPPED_SKILL, { force = false } = {}) {
98
152
  const shipped = await readShippedSkill(name);
99
153
  const file = path.join(skillsDir, name, 'SKILL.md');
100
154
  const existing = (await fs.pathExists(file)) ? await fs.readFile(file, 'utf8') : null;
@@ -102,8 +156,10 @@ export async function installClaudeSkill(skillsDir, name = SHIPPED_SKILL) {
102
156
  const viaSymlink = await isSymlink(file);
103
157
  const base = {
104
158
  name,
159
+ contentState: existing === null ? null : classifySkillContent(existing, shipped.content),
105
160
  file,
106
161
  viaSymlink,
162
+ shippedFile: shipped.file,
107
163
  realFile: viaSymlink ? await fs.realpath(file) : file,
108
164
  installedVersion,
109
165
  shippedVersion: shipped.version,
@@ -111,7 +167,13 @@ export async function installClaudeSkill(skillsDir, name = SHIPPED_SKILL) {
111
167
  if (installedVersion && isNewerVersion(installedVersion, shipped.version)) {
112
168
  return { ...base, status: 'declined-downgrade' };
113
169
  }
114
- const next = stampSkillVersion(shipped.content, shipped.version);
170
+ // Only `pristine` content is provably ours to replace. Anything else is a
171
+ // fork (or unprovable, which for a destructive write is the same thing) and
172
+ // stays a human decision — the same rule `fix copied-assets` follows (#448).
173
+ if (!force && base.contentState !== null && base.contentState !== 'pristine') {
174
+ return { ...base, status: 'declined-fork' };
175
+ }
176
+ const next = stampSkill(shipped.content, shipped.version);
115
177
  if (existing === next)
116
178
  return { ...base, status: 'up-to-date' };
117
179
  await fs.ensureDir(path.dirname(file));
@@ -120,12 +182,13 @@ export async function installClaudeSkill(skillsDir, name = SHIPPED_SKILL) {
120
182
  }
121
183
  /** Read-only counterpart of `installClaudeSkill`, for doctor. */
122
184
  export async function claudeSkillStatus(name = SHIPPED_SKILL, explicit) {
123
- const { version } = await readShippedSkill(name);
185
+ const shipped = await readShippedSkill(name);
124
186
  const { dir } = await resolveSkillsDir(explicit);
125
187
  const absent = {
126
188
  installed: false,
127
189
  installedVersion: null,
128
- shippedVersion: version,
190
+ shippedVersion: shipped.version,
191
+ contentState: null,
129
192
  needsInstall: true,
130
193
  };
131
194
  if (!dir)
@@ -133,12 +196,16 @@ export async function claudeSkillStatus(name = SHIPPED_SKILL, explicit) {
133
196
  const file = path.join(dir, name, 'SKILL.md');
134
197
  if (!(await fs.pathExists(file)))
135
198
  return { file, ...absent };
136
- const installedVersion = readSkillVersion(await fs.readFile(file, 'utf8'));
199
+ const content = await fs.readFile(file, 'utf8');
200
+ const installedVersion = readSkillVersion(content);
201
+ const contentState = classifySkillContent(content, shipped.content);
202
+ const behind = installedVersion === null || isNewerVersion(shipped.version, installedVersion);
137
203
  return {
138
204
  file,
139
205
  installed: true,
140
206
  installedVersion,
141
- shippedVersion: version,
142
- needsInstall: installedVersion === null || isNewerVersion(version, installedVersion),
207
+ shippedVersion: shipped.version,
208
+ contentState,
209
+ needsInstall: behind && contentState === 'pristine',
143
210
  };
144
211
  }
package/dist/cli/index.js CHANGED
@@ -329,6 +329,9 @@ program
329
329
  .option('--resync', 'Re-scaffold every file recorded in .repo-tooling.json')
330
330
  .option('--diff', 'Show a unified diff of each change before confirming')
331
331
  .option('--skills-dir <path>', 'Where `fix claude-skills` installs user-global agent skills (default: ~/.claude/skills). Required with --yes/--json when that directory does not exist')
332
+ // Deliberately not folded into --yes: unattended runs pass --yes, and this is
333
+ // the one overwrite that destroys work living outside the repo (#480).
334
+ .option('--force-skills', 'Let `fix claude-skills` overwrite a locally modified skill instead of refusing')
332
335
  .action((target, options) => fixCommand(target, {
333
336
  directory: options.directory,
334
337
  yes: options.yes,
@@ -338,6 +341,7 @@ program
338
341
  resync: options.resync,
339
342
  diff: options.diff,
340
343
  skillsDir: options.skillsDir,
344
+ forceSkills: options.forceSkills,
341
345
  }));
342
346
  program.hook('preAction', async (_, actionCommand) => {
343
347
  const name = actionCommand.name();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rtorcato/repo-tooling",
3
- "version": "3.16.2",
3
+ "version": "3.17.0",
4
4
  "description": "One CLI to scaffold, audit and fix your repo's whole toolchain — linting, tests, commits, releases & CI.",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -43,14 +43,12 @@ real approvals.
43
43
 
44
44
  The same constraint makes everything an agent posts *look* hand-written by the
45
45
  owner. So **every comment any agent leaves — review, blocked, gave-up, declined —
46
- opens with a `🤖 *Automated …*` italic header line** naming which agent wrote it,
47
- followed by a blank line. Non-negotiable: a detailed security review under a
48
- human's avatar misrepresents who reviewed the code.
46
+ opens with a `🤖 *Automated …*` italic header line naming which agent wrote it**,
47
+ then a blank line. Name the agent and stop there: a detailed security review
48
+ under a human's avatar misrepresents who reviewed the code, but *why* it wears
49
+ that avatar is read once and then reread on every comment forever.
49
50
 
50
- Spell the reason out rather than assuming the reader knows the convention — the
51
- header names the agent *and* says why it is wearing a human's face:
52
-
53
- `🤖 *Automated — <which agent> via ai-issue-loop. Posted under the owner's account by an agent; not a human message. There is no separate GitHub account for AI agents, so this appears under @<owner>'s avatar.*`
51
+ `🤖 *Automated — <which agent> via ai-issue-loop.*`
54
52
 
55
53
  **Comment budget: ≤10 lines, and a clean outcome gets no comment at all.** A
56
54
  40-line comment on every PR trains the reader to skip all of them, including the
@@ -63,7 +61,7 @@ drift with a second copy to maintain.
63
61
  | Clean and ready | **None.** `ai-ok-code, ai-ok-sec` + assigned + no `ai-review` already says it. |
64
62
  | `ai-notes` | ≤10 lines; link the reviewer's `### Before merging`. |
65
63
  | `ai-changes`, CI red, `ai-blocked` | ≤10 lines, action first, then the specific cause. |
66
- | Reviewer verdict | `### Before merging` plus at most 3 short paragraphs above it. |
64
+ | Reviewer verdict | `### Before merging` plus ≤600 characters above it. |
67
65
  | Declining an issue | The one exception — a hard handoff needs its reasoning; see Pass 4. |
68
66
 
69
67
  ## Labels
@@ -448,7 +446,7 @@ ping-pong stop and an implementer handing back, leave it off for the same reason
448
446
  **Every `ai-blocked` must say why, and land in front of a human.** So reaping always
449
447
  does three things together — label, assign, comment — and the comment opens with
450
448
 
451
- `🤖 *Automated — \`ai-issue-loop\` Pass 2 (stall reaping). Posted under the owner's account; not a human message.*`
449
+ `🤖 *Automated — \`ai-issue-loop\` Pass 2 (stall reaping).*`
452
450
 
453
451
  then a blank line. State which stall rule fired, how long the label sat, and whether a
454
452
  worktree was removed. A bare `ai-blocked` with no explanation is worse than no label:
@@ -506,20 +504,19 @@ Reviewer prompt template:
506
504
  > purpose. Also read the repo's `CLAUDE.md` if the diff plausibly touches a rule
507
505
  > it states.
508
506
  >
509
- > `<code-reviewer: Judge correctness, obvious bugs, and adherence to the repo's
510
- > stated conventions.>` / `<security-expert: Judge injection risk, leaked
511
- > secrets, unsafe shell/SQL construction, and dependency or supply-chain
512
- > changes.>`
507
+ > `<code-reviewer: Judge correctness, obvious bugs, and adherence to the repo's stated
508
+ > conventions.>` / `<security-expert: Judge injection risk, leaked secrets, unsafe
509
+ > shell/SQL construction, and dependency or supply-chain changes.>` That is the
510
+ > checklist to run, not an outline to write up.
513
511
  >
514
512
  > Post your verdict as a comment — **never** `--approve`, it errors on your own
515
513
  > PR:
516
514
  > `gh pr review <N> --comment --body "..."`
517
515
  >
518
- > The body **must** begin with this exact header line, then a blank line. Every
519
- > agent authenticates as the repo owner, so without it the timeline reads as if
520
- > a human wrote the review:
516
+ > The body **must** begin with this exact header line, then a blank line — you
517
+ > authenticate as the repo owner, so without it the review reads as a human's:
521
518
  >
522
- > `🤖 *Automated review — \`<your agent type>\` via ai-issue-loop. Posted under the owner's account; not a human review.*`
519
+ > `🤖 *Automated review — \`<your agent type>\` via ai-issue-loop.*`
523
520
  >
524
521
  > The body **must end** with this section, as its last thing:
525
522
  >
@@ -538,13 +535,14 @@ Reviewer prompt template:
538
535
  > That section is what a human reads at merge time, so put anything you would
539
536
  > want them to know there rather than leaving it in the prose above — a finding
540
537
  > buried mid-paragraph does not survive the handoff. For the same reason, **cap
541
- > the body at that section plus at most 3 short paragraphs above it**: no process
542
- > narration, no restating the diff, no listing what you checked and found fine.
543
- > The bar is a finding that
544
- > **changes what a human would do**: a semver implication, a deliberate
545
- > omission, a follow-up that must be filed. Not observations, not praise, not
546
- > restating the diff. Writing `Nothing.` is a real verdict and the common one —
547
- > say it plainly rather than padding the section to look thorough.
538
+ > the body at that section plus ≤600 characters above it**. Verify everything;
539
+ > narrate only where the PR is **wrong** or **silent**. Never list what you
540
+ > checked and found clean, and never confirm a claim the PR body already makes —
541
+ > agreement is what the pass label is for, so a review that agrees is nearly
542
+ > empty. The bar is a finding that **changes what a human would do**: a semver
543
+ > implication, a deliberate omission, a follow-up that must be filed. Writing
544
+ > `Nothing.` is a real verdict and the common one — say it plainly rather than
545
+ > padding to look thorough.
548
546
  >
549
547
  > Then apply exactly one verdict label, **clearing your claim label in the same
550
548
  > command**:
@@ -646,8 +644,8 @@ package/from/to table survives because it sits at the top; classify from that.
646
644
  > State in your comment which rule fired, name the packages that tripped it, and say
647
645
  > whether the body was truncated so the reader knows what you could and couldn't see.
648
646
  > Same `🤖 *Automated review — …*` header line, same closing `### Before merging`
649
- > section, same 3-paragraph cap on the body, and same one-verdict-label rule as
650
- > above — **including clearing your
647
+ > section, same ≤600-character cap and no-negative-findings rule on the body, and same
648
+ > one-verdict-label rule as above — **including clearing your
651
649
  > `<ai-reviewing-code|ai-reviewing-sec>` claim label in the same `gh pr edit`**.
652
650
  > Pass 3 claimed you with it before spawning you, and a claim left behind wedges
653
651
  > your half of the review until Pass 2 reaps it.
@@ -682,7 +680,7 @@ gh api "repos/$OWNER_REPO/issues/<N>/timeline" \
682
680
  ```
683
681
 
684
682
  If that count is **≥ 3**, stop looping. Comment the reason on the PR — opening with
685
- `🤖 *Automated — \`ai-issue-loop\` Pass 3. Posted under the owner's account; not a human message.*`
683
+ `🤖 *Automated — \`ai-issue-loop\` Pass 3.*`
686
684
  and a blank line — naming what each round changed and why the reviewer kept objecting,
687
685
  then:
688
686
 
@@ -751,10 +749,9 @@ same issue gets re-triaged from scratch every time, and the reasoning that took
751
749
  real work to reach is lost.
752
750
 
753
751
  The comment opens with the standard `🤖 *Automated …*` header — see the top of this
754
- file; it must state that no GitHub account exists for AI agents, so the comment
755
- wears the owner's avatar. Then, in the body — **this is the one comment exempt
756
- from the ≤10-line budget, and only this one.** Declining is a hard handoff whose
757
- whole value is the reasoning; do not reach for this shape on a PR handoff:
752
+ file. Then, in the body — **this is the one comment exempt from the ≤10-line
753
+ budget, and only this one.** Declining is a hard handoff whose whole value is the
754
+ reasoning; do not reach for this shape on a PR handoff:
758
755
 
759
756
  - **Why an agent cannot finish it**, concretely. "Not suitable" is useless. Name
760
757
  the blocker: binary assets it cannot author, a force-push past branch
@@ -879,7 +876,7 @@ Then spawn a background implementer agent:
879
876
  > **must** open with this exact line, then a blank line — you authenticate as the
880
877
  > owner, so without it the issue reads as if they wrote it themselves:
881
878
  >
882
- > `🤖 *Automated — implementer via ai-issue-loop. Posted under the owner's account; not a human message.*`
879
+ > `🤖 *Automated — implementer via ai-issue-loop.*`
883
880
  >
884
881
  > Say what you tried, the exact error, and what a human would need to decide. "Could
885
882
  > not finish" with no detail wastes the handoff — the whole point of the label is that