@rtorcato/repo-tooling 3.43.0 → 3.44.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.md CHANGED
@@ -30,6 +30,7 @@ Every command supports `--json` and a non-interactive mode. Combine with `--yes`
30
30
  | `loop env` | ✅ | ✅ | Resolve an `ai-issue-loop` tick's variables once: `root` (main checkout, correct from inside a worktree), `worktreeRoot`, `ownerRepo`, `agentUser` (empty unless an assignable collaborator), `humanUser` (empty for org repos), `me`. Without `--json`, prints `KEY='value'` lines for `eval`. Exit `1` when the checkout or its GitHub repo cannot be resolved. |
31
31
  | `loop worktree add <ai-N-slug>` | ✅ | ✅ | Create an `ai-issue-loop` worktree at `<root>-worktrees/<slug>` on a new branch off `origin/main` (`--base`), symlink every `worktree.symlinkDirectories` entry from `.claude/settings.json`, and add `node_modules` to `.git/info/exclude`. `needsInstall: true` when no list is declared. Exit `1` when the worktree was not created or an entry is left unlinked — do not spawn an implementer. |
32
32
  | `loop reap --json` | ✅ | ✅ | Report the `ai-issue-loop` Pass 2 stalls: a label that sat ≥45 minutes past its last application (per the issue timeline). Each entry is `{ kind, issue, pr, label, minutes, applications, action, worktree, reason }` — `kind` is `implementer` / `reviewer` / `fixer` / `orphan`, `action` is `block` / `drop-label` / `remove-worktree` (`block` once a claim has been applied ≥3 times). Read-only; exit `1` when a `gh` query failed. |
33
+ | `loop comment <pr> --body-file <path>` / `loop verdict <pr> --arm <code\|sec>` | ✅ | ✅ | The loop's hidden-marker protocols. `comment` upserts the one `<!-- ai-issue-loop:decision -->` comment (body from a file or `-` stdin, never argv). `verdict` prints the arm's `PASS` / `PASS-NOTES` / `CHANGES` for the PR's current head, or empty. Both only trust markers posted by `gh`'s own login. Exit `1` when GitHub cannot be read. |
33
34
 
34
35
  ## Recommended workflows
35
36
 
@@ -123,6 +124,7 @@ A fixer may also **refuse** — the target file holds something the generator ca
123
124
  - `src/cli/commands/loop-env.ts` — `loop env`: the skill's Pass 0 variables resolved in one call (#615)
124
125
  - `src/cli/commands/loop-worktree.ts` — `loop worktree add`: Pass 4 worktree creation, dependency linking and the missing-link assertion (#616)
125
126
  - `src/cli/commands/loop-reap.ts` — `loop reap`: the skill's Pass 2 stalled-agent table as verdicts (#618)
127
+ - `src/cli/commands/loop-marker.ts` — `loop comment` / `loop verdict`: the decision-comment upsert and verdict read-back, with the author and head-commit gates (#619)
126
128
  - `src/cli/generators/` — one file per concern (linting, testing, build, git, github-actions, security, misc)
127
129
  - `tooling/` — every shipped preset, mirrored 1:1 with `package.json` `exports`
128
130
 
@@ -3,7 +3,7 @@ import { LOGIN } from '../../base/agent-user.js';
3
3
  import { realGitExec } from '../../base/git-identity.js';
4
4
  import { realGhExec } from '../../base/github-settings.js';
5
5
  import { configuredAgentUser, defaultWorktreeRoot } from './loop-guard.js';
6
- async function ghOut(gh, args) {
6
+ export async function ghOut(gh, args) {
7
7
  const r = await gh(args);
8
8
  return r.ok ? r.stdout.trim() : '';
9
9
  }
@@ -0,0 +1,169 @@
1
+ import path from 'node:path';
2
+ import fs from 'fs-extra';
3
+ import { realGhExec } from '../../base/github-settings.js';
4
+ import { ghOut } from './loop-env.js';
5
+ /**
6
+ * `repo-tooling loop comment` and `loop verdict` — the ai-issue-loop skill's
7
+ * two hidden-marker protocols, moved out of prose-with-jq (#619):
8
+ *
9
+ * - **comment** upserts the one `<!-- ai-issue-loop:decision -->` comment on a
10
+ * PR (Pass 1), so a PR left over a weekend gets one edited comment, not one
11
+ * per tick.
12
+ * - **verdict** reads a reviewer arm's `<!-- ai-issue-loop:verdict:… -->`
13
+ * marker back off the PR's reviews (Pass 3), so a reviewer that posted and
14
+ * died before labelling is adopted rather than re-spawned.
15
+ *
16
+ * Both gate on the loop's own login — anyone can comment on or review a public
17
+ * PR, and a stranger's marker would otherwise own the slot or override a
18
+ * genuine `CHANGES`. Login, not `author_association`, which wobbles with repo
19
+ * ownership. Bodies travel to gh as JSON on stdin, never through a shell.
20
+ */
21
+ export const DECISION_MARKER = '<!-- ai-issue-loop:decision -->';
22
+ async function resolveTarget(dir, gh) {
23
+ const cwd = path.resolve(dir ?? process.cwd());
24
+ const exec = gh ?? ((args, stdin) => realGhExec(args, stdin, cwd));
25
+ const ownerRepo = await ghOut(exec, [
26
+ 'repo',
27
+ 'view',
28
+ '--json',
29
+ 'nameWithOwner',
30
+ '--jq',
31
+ '.nameWithOwner',
32
+ ]);
33
+ if (!ownerRepo)
34
+ return 'could not resolve the GitHub repo from the working directory';
35
+ const me = await ghOut(exec, ['api', 'user', '--jq', '.login']);
36
+ if (!me)
37
+ return 'could not resolve the gh login';
38
+ return { ownerRepo, me, gh: exec };
39
+ }
40
+ /** The PR number goes into an API path — digits only. */
41
+ function prNumber(pr) {
42
+ const s = String(pr);
43
+ return /^[1-9]\d*$/.test(s) ? Number(s) : null;
44
+ }
45
+ /** `--paginate --slurp` yields one array per page; `--jq` would run per page. */
46
+ async function slurp(gh, route) {
47
+ const r = await gh(['api', route, '--paginate', '--slurp']);
48
+ if (!r.ok)
49
+ return null;
50
+ try {
51
+ return JSON.parse(r.stdout).flat();
52
+ }
53
+ catch {
54
+ return null;
55
+ }
56
+ }
57
+ export async function runLoopComment(pr, options) {
58
+ const n = prNumber(pr);
59
+ const fail = (error) => ({
60
+ pr: n,
61
+ action: 'failed',
62
+ commentId: null,
63
+ error,
64
+ });
65
+ if (n === null)
66
+ return fail(`not a PR number: ${pr}`);
67
+ const text = options.text.trim();
68
+ if (!text)
69
+ return fail('empty comment body');
70
+ const target = await resolveTarget(options.dir, options.gh);
71
+ if (typeof target === 'string')
72
+ return fail(target);
73
+ const { ownerRepo, me, gh } = target;
74
+ const comments = await slurp(gh, `repos/${ownerRepo}/issues/${n}/comments`);
75
+ // A failed read must not fall through to "create" — that is the duplicate.
76
+ if (comments === null)
77
+ return fail('could not list PR comments');
78
+ const existing = comments.find((c) => c.user?.login === me && (c.body ?? '').startsWith(DECISION_MARKER));
79
+ const input = JSON.stringify({ body: `${DECISION_MARKER}\n${text}` });
80
+ const r = existing
81
+ ? await gh(['api', '-X', 'PATCH', `repos/${ownerRepo}/issues/comments/${existing.id}`, '--input', '-'], input)
82
+ : await gh(['api', `repos/${ownerRepo}/issues/${n}/comments`, '--input', '-'], input);
83
+ if (!r.ok)
84
+ return fail(r.stderr.trim() || 'gh api failed');
85
+ let id = existing?.id ?? null;
86
+ try {
87
+ id = JSON.parse(r.stdout).id ?? id;
88
+ }
89
+ catch { }
90
+ return { pr: n, action: existing ? 'updated' : 'created', commentId: id };
91
+ }
92
+ export async function runLoopVerdict(pr, options) {
93
+ const n = prNumber(pr);
94
+ const { arm } = options;
95
+ const fail = (error) => ({
96
+ pr: n,
97
+ arm,
98
+ head: null,
99
+ verdict: null,
100
+ error,
101
+ });
102
+ if (n === null)
103
+ return fail(`not a PR number: ${pr}`);
104
+ if (arm !== 'code' && arm !== 'sec')
105
+ return fail(`--arm must be code or sec, got ${arm}`);
106
+ const target = await resolveTarget(options.dir, options.gh);
107
+ if (typeof target === 'string')
108
+ return fail(target);
109
+ const { ownerRepo, me, gh } = target;
110
+ const head = await ghOut(gh, [
111
+ 'pr',
112
+ 'view',
113
+ String(n),
114
+ '-R',
115
+ ownerRepo,
116
+ '--json',
117
+ 'headRefOid',
118
+ '--jq',
119
+ '.headRefOid',
120
+ ]);
121
+ if (!head)
122
+ return fail('could not resolve the PR head commit');
123
+ // `issues/<N>/comments` would never see these — `gh pr review --comment`
124
+ // creates a review.
125
+ const reviews = await slurp(gh, `repos/${ownerRepo}/pulls/${n}/reviews`);
126
+ if (reviews === null)
127
+ return { ...fail('could not list PR reviews'), head };
128
+ const marker = new RegExp(`<!-- ai-issue-loop:verdict:${arm}:(PASS-NOTES|PASS|CHANGES) -->`);
129
+ let verdict = null;
130
+ for (const r of reviews) {
131
+ // The head gate: a verdict expires with the diff it read.
132
+ if (r.user?.login !== me || r.commit_id !== head)
133
+ continue;
134
+ const m = (r.body ?? '').match(marker);
135
+ if (m)
136
+ verdict = m[1];
137
+ }
138
+ return { pr: n, arm, head, verdict };
139
+ }
140
+ export async function loopCommentCommand(pr, options) {
141
+ let text;
142
+ try {
143
+ text = fs.readFileSync(options.bodyFile === '-' ? 0 : options.bodyFile, 'utf8');
144
+ }
145
+ catch (err) {
146
+ console.error(`✖ cannot read ${options.bodyFile}: ${err.message}`);
147
+ process.exitCode = 1;
148
+ return;
149
+ }
150
+ const result = await runLoopComment(pr, { dir: options.dir, text });
151
+ if (options.json)
152
+ console.log(JSON.stringify(result, null, 2));
153
+ else if (result.error)
154
+ console.error(`✖ ${result.error}`);
155
+ else
156
+ console.log(`${result.action} comment ${result.commentId} on #${result.pr}`);
157
+ process.exitCode = result.action === 'failed' ? 1 : 0;
158
+ }
159
+ export async function loopVerdictCommand(pr, options) {
160
+ const result = await runLoopVerdict(pr, options);
161
+ if (options.json)
162
+ console.log(JSON.stringify(result, null, 2));
163
+ else if (result.error)
164
+ console.error(`✖ ${result.error}`);
165
+ // Empty line for "none", so `VERDICT=$(… loop verdict …)` needs no null check.
166
+ else
167
+ console.log(result.verdict ?? '');
168
+ process.exitCode = result.error ? 1 : 0;
169
+ }
package/dist/cli/index.js CHANGED
@@ -8,6 +8,7 @@ import { fixCommand } from './commands/fix.js';
8
8
  import { loopCleanupCommand } from './commands/loop-cleanup.js';
9
9
  import { loopEnvCommand } from './commands/loop-env.js';
10
10
  import { loopGuardCommand } from './commands/loop-guard.js';
11
+ import { loopCommentCommand, loopVerdictCommand } from './commands/loop-marker.js';
11
12
  import { loopReapCommand } from './commands/loop-reap.js';
12
13
  import { loopWorktreeAddCommand } from './commands/loop-worktree.js';
13
14
  import { setupProject } from './commands/setup.js';
@@ -412,6 +413,22 @@ loop
412
413
  .addHelpText('after', '\nRead-only: prints a verdict per stall (block / drop-label / remove-worktree).\n' +
413
414
  'Exits 1 when a gh query failed and the report is incomplete.\n')
414
415
  .action(loopReapCommand);
416
+ loop
417
+ .command('comment <pr>')
418
+ .description("💬 Upsert the loop's one decision-marker comment on a PR")
419
+ .requiredOption('--body-file <path>', 'Comment text, without the marker (- for stdin)')
420
+ .option('-d, --dir <path>', 'Directory to resolve the GitHub repo from', process.cwd())
421
+ .option('--json', 'Emit machine-readable JSON output')
422
+ .action(loopCommentCommand);
423
+ loop
424
+ .command('verdict <pr>')
425
+ .description("⚖️ Read a reviewer arm's verdict marker for the PR's current head")
426
+ .requiredOption('--arm <arm>', 'Reviewer arm: code or sec')
427
+ .option('-d, --dir <path>', 'Directory to resolve the GitHub repo from', process.cwd())
428
+ .option('--json', 'Emit machine-readable JSON output')
429
+ .addHelpText('after', '\nPrints PASS, PASS-NOTES, CHANGES, or an empty line when none is posted.\n' +
430
+ 'Exits 1 when the PR or its reviews cannot be read.\n')
431
+ .action(loopVerdictCommand);
415
432
  program.hook('preAction', async (_, actionCommand) => {
416
433
  const name = actionCommand.name();
417
434
  if (name === 'setup' || name === 'doctor' || name === 'fix') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rtorcato/repo-tooling",
3
- "version": "3.43.0",
3
+ "version": "3.44.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": [