@remits/remits-cli 0.1.112 → 0.1.114

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/index.js CHANGED
@@ -24,7 +24,10 @@ const SESSIONS_FILE = path.join(SESSION_DIR, 'sessions.json');
24
24
  const CONFIG_FILE = path.join(SESSION_DIR, 'config.json');
25
25
  const LISTENER_PID_FILE = path.join(SESSION_DIR, 'listener.pid');
26
26
  const ACTIVITY_LOG_FILE = path.join(SESSION_DIR, 'activity.log');
27
- const SKILL_SOURCE_FILE = path.join(__dirname, 'skills', 'remits-cli', 'SKILL.md');
27
+ const SKILL_SOURCE_DIR = path.join(__dirname, 'skills', 'remits-cli');
28
+ const SKILL_SOURCE_FILE = path.join(SKILL_SOURCE_DIR, 'SKILL.md');
29
+ const SKILL_REFERENCES_DIRNAME = 'references';
30
+ const SKILL_REFERENCES_SOURCE_DIR = path.join(SKILL_SOURCE_DIR, SKILL_REFERENCES_DIRNAME);
28
31
  const ACCOUNT_REPO_INDEX_FILE = path.join(SESSION_DIR, 'account-repos.json');
29
32
  const SERVICE_STATE_FILE = path.join(SESSION_DIR, 'service-state.json');
30
33
  const ISSUES_DIR = path.join(SESSION_DIR, 'issues');
@@ -1248,13 +1251,45 @@ function countAccountRepos(index) {
1248
1251
  return listAccountRepoEntries(index).length;
1249
1252
  }
1250
1253
 
1254
+ /**
1255
+ * Fold discovered checkouts into the index, one entry per account.
1256
+ *
1257
+ * <p><b>Two checkouts can claim one account, and the naive merge picked a silent winner.</b> The index is
1258
+ * keyed by accountId, so a plain last-write-wins loop resolved a fork, a worktree or a dated backup copy
1259
+ * by scan order — which is alphabetical, which is meaningless. Measured on this machine: account 33 had
1260
+ * both `remits-invoice-automation` and `remits-invoice-automation copy-EOD-09302025`, and the index held
1261
+ * the dated copy. Four open tickets would have been worked in a month-old snapshot.</p>
1262
+ *
1263
+ * <p>So: a real checkout always beats a non-checkout (an unpacked export cannot win merely by sorting
1264
+ * later); an explicit `cwd` entry — the operator standing in the directory — always beats a scan; and
1265
+ * when two genuine checkouts remain, the incumbent is KEPT and the loser is recorded in `ambiguousWith`
1266
+ * rather than discarded. Ambiguity a person can see is recoverable; a silent winner is not.</p>
1267
+ */
1251
1268
  function mergeAccountRepoEntries(entries, options = {}) {
1252
1269
  const nextIndex = options.existingIndex ? { ...options.existingIndex } : {};
1270
+ const rank = (entry) => (isAccountCheckout(entry.directory) ? 2 : 0) + (entry.source === 'cwd' ? 1 : 0);
1271
+
1253
1272
  for (const entry of entries) {
1254
1273
  if (!entry || !entry.accountId || !entry.directory) {
1255
1274
  continue;
1256
1275
  }
1257
- nextIndex[String(entry.accountId)] = entry;
1276
+ const key = String(entry.accountId);
1277
+ const current = nextIndex[key];
1278
+ if (!current || !current.directory || current.directory === entry.directory) {
1279
+ nextIndex[key] = { ...entry, ambiguousWith: (current && current.ambiguousWith) || [] };
1280
+ continue;
1281
+ }
1282
+
1283
+ const winner = rank(entry) > rank(current) ? entry : current;
1284
+ const loser = winner === entry ? current : entry;
1285
+ const seen = new Set([...((current.ambiguousWith) || []), ...((entry.ambiguousWith) || [])]);
1286
+ // Only a genuine rival is worth reporting. A non-checkout losing to a checkout is the system
1287
+ // working, not a decision the operator needs to review.
1288
+ if (isAccountCheckout(loser.directory) && isAccountCheckout(winner.directory)) {
1289
+ seen.add(loser.directory);
1290
+ }
1291
+ seen.delete(winner.directory);
1292
+ nextIndex[key] = { ...winner, ambiguousWith: [...seen] };
1258
1293
  }
1259
1294
  writeAccountRepoIndex(nextIndex);
1260
1295
  return nextIndex;
@@ -1265,7 +1300,6 @@ function updateAccountRepoIndex(cwd) {
1265
1300
  if (!info) {
1266
1301
  return;
1267
1302
  }
1268
- try { syncInstalledSkills(); } catch (_) { /* best-effort */ }
1269
1303
  const entry = buildAccountRepoEntryFromInfo(info, cwd, 'cwd');
1270
1304
  if (!entry) {
1271
1305
  return;
@@ -1339,7 +1373,6 @@ function discoverAccountRepos() {
1339
1373
  for (const root of roots) {
1340
1374
  walkForAccountInfo(root, 0, matches, visited, platformMatches);
1341
1375
  }
1342
- try { syncInstalledSkills(); } catch (_) { /* best-effort */ }
1343
1376
 
1344
1377
  const entries = [];
1345
1378
  for (const file of matches) {
@@ -1892,13 +1925,166 @@ async function loggedGet(api, cwd, endpoint, params = {}) {
1892
1925
  }
1893
1926
  }
1894
1927
 
1928
+ // ---------------------------------------------------------------------------
1929
+ // Skill delivery
1930
+ //
1931
+ // The skill is SKILL.md (the index and the rules) plus the reference files in
1932
+ // skills/remits-cli/references/. Both ship inside the npm package, so the whole
1933
+ // skill is available before the CLI has ever authenticated.
1934
+ //
1935
+ // Every delivered markdown file goes through deliverSkillMarkdown(), which is
1936
+ // the ONE delivery path: it substitutes the machine-specific placeholders and
1937
+ // resolves each file's table of contents to real line numbers. Writing a file
1938
+ // out with fs.writeFileSync + readFileSync instead would ship the same skill
1939
+ // with a different table of contents depending on which door it came out of.
1940
+ // ---------------------------------------------------------------------------
1941
+
1942
+ // GitHub-style heading anchor: lowercase, punctuation dropped, spaces to hyphens.
1943
+ function headingSlug(headingText) {
1944
+ return String(headingText || '')
1945
+ .toLowerCase()
1946
+ .replace(/[^a-z0-9 _-]/g, '')
1947
+ .trim()
1948
+ .replace(/\s/g, '-');
1949
+ }
1950
+
1951
+ function formatTocEntry(indent, target) {
1952
+ if (!target) return null;
1953
+ return indent + '- L' + target.line + ' ' + target.title;
1954
+ }
1955
+
1956
+ // Rewrites a document's "## Table of Contents" entries to carry the line number of
1957
+ // the heading they point at, turning the TOC into a jump table:
1958
+ //
1959
+ // - [Component Structure](#component-structure) -> - L59 Component Structure
1960
+ // - Line 31: Component Structure -> - L59 Component Structure
1961
+ //
1962
+ // Numbers are computed HERE, at delivery, never written into the repo file.
1963
+ // Hand-maintained line numbers rot on the first edit above them, and a WRONG
1964
+ // number is worse than none: it sends the reader to the wrong section, where it
1965
+ // may conclude the content is missing. Resolving at delivery means the numbers
1966
+ // cannot be stale, while the repo file keeps anchor links that survive edits and
1967
+ // produce clean diffs.
1968
+ //
1969
+ // INVARIANT: every rewrite is one line in, one line out. The line count must not
1970
+ // change, or the numbers computed against the input would no longer describe the
1971
+ // output. Locked down by test/skill-references.test.js.
1972
+ //
1973
+ // An entry whose target heading cannot be resolved is left exactly as written —
1974
+ // it degrades to the original text, never to a guessed number.
1975
+ function resolveTableOfContentsLineNumbers(markdown) {
1976
+ if (!markdown || !markdown.trim()) {
1977
+ return markdown;
1978
+ }
1979
+
1980
+ const lines = markdown.split('\n');
1981
+ const tocStart = lines.findIndex((line) => /^##\s+table of contents\s*$/i.test(line));
1982
+ if (tocStart < 0) {
1983
+ return markdown;
1984
+ }
1985
+
1986
+ let tocEnd = lines.length;
1987
+ for (let i = tocStart + 1; i < lines.length; i += 1) {
1988
+ if (/^##(?!#)\s+.*$/.test(lines[i])) {
1989
+ tocEnd = i;
1990
+ break;
1991
+ }
1992
+ }
1993
+
1994
+ // A `#`-prefixed line inside a fenced code block is CONTENT, not a heading. The
1995
+ // references routinely show markdown and shell samples that contain one. Indexing
1996
+ // those shifts GitHub's duplicate-slug disambiguation (-1, -2 ...) out of step with
1997
+ // the real headings, so a legitimate repeated heading resolves to the wrong line.
1998
+ const byAnchor = new Map();
1999
+ const byTitle = new Map();
2000
+ const slugCounts = new Map();
2001
+ let inFence = false;
2002
+
2003
+ lines.forEach((line, index) => {
2004
+ if (/^\s{0,3}(```|~~~)/.test(line)) {
2005
+ inFence = !inFence;
2006
+ return;
2007
+ }
2008
+ if (inFence) return;
2009
+
2010
+ const heading = /^(#{1,6})\s+(.+?)\s*$/.exec(line);
2011
+ if (!heading) return;
2012
+
2013
+ const title = heading[2].trim();
2014
+ const target = { line: index + 1, title };
2015
+
2016
+ const baseSlug = headingSlug(title);
2017
+ const seen = slugCounts.get(baseSlug) || 0;
2018
+ slugCounts.set(baseSlug, seen + 1);
2019
+ const slug = seen === 0 ? baseSlug : baseSlug + '-' + seen;
2020
+
2021
+ if (!byAnchor.has(slug)) byAnchor.set(slug, target);
2022
+ if (!byTitle.has(title)) byTitle.set(title, target);
2023
+ });
2024
+
2025
+ let changed = false;
2026
+ for (let i = tocStart + 1; i < tocEnd; i += 1) {
2027
+ const rewritten = resolveTocEntry(lines[i], byAnchor, byTitle);
2028
+ if (rewritten !== null) {
2029
+ lines[i] = rewritten;
2030
+ changed = true;
2031
+ }
2032
+ }
2033
+
2034
+ return changed ? lines.join('\n') : markdown;
2035
+ }
2036
+
2037
+ // The rewritten entry carries the ACTUAL heading text, not the TOC's own link text.
2038
+ // The two drift (a heading gets reworded and the TOC entry does not), and emitting
2039
+ // the heading text both self-heals that drift and guarantees the entry is a literal
2040
+ // match for the heading — so an agent can grep it as-is rather than trusting the number.
2041
+ //
2042
+ // Returns null when the line is not a resolvable TOC entry and must be left untouched.
2043
+ function resolveTocEntry(line, byAnchor, byTitle) {
2044
+ const anchorEntry = /^(\s*)[-*]\s+\[(.+)\]\(#([^)\s]+)\)\s*$/.exec(line);
2045
+ if (anchorEntry) {
2046
+ return formatTocEntry(anchorEntry[1], byAnchor.get(anchorEntry[3]));
2047
+ }
2048
+
2049
+ // Legacy hand-written form. Re-resolving it corrects files that have not been
2050
+ // audited yet, instead of shipping their stale numbers.
2051
+ const legacyEntry = /^(\s*)[-*]\s+Line\s+\d+:\s*(.+?)\s*$/.exec(line);
2052
+ if (legacyEntry) {
2053
+ const title = legacyEntry[2];
2054
+ return formatTocEntry(legacyEntry[1], byTitle.get(title) || byAnchor.get(headingSlug(title)));
2055
+ }
2056
+
2057
+ return null;
2058
+ }
2059
+
2060
+ // THE single delivery path for a skill markdown file. Substitutes the placeholders
2061
+ // that only the installing machine can fill in, then resolves the table of contents.
2062
+ function deliverSkillMarkdown(sourceText, referencesDir) {
2063
+ let body = String(sourceText);
2064
+ body = body.split('{{ACCOUNT_REPO_INDEX_PATH}}').join(ACCOUNT_REPO_INDEX_FILE);
2065
+ body = body.split('{{SKILL_REFERENCES_DIR}}').join(referencesDir);
2066
+ return resolveTableOfContentsLineNumbers(body);
2067
+ }
2068
+
1895
2069
  function resolveSkillTargets(target) {
1896
2070
  const value = String(target || 'all').toLowerCase();
1897
2071
  const home = os.homedir();
2072
+ // Gemini reads a FLAT skill file, so its references cannot sit beside it as
2073
+ // `references/`. That is why SKILL.md states the reference directory as an
2074
+ // absolute path substituted per target rather than a relative one.
1898
2075
  const targets = {
1899
- codex: path.join(home, '.codex', 'skills', 'remits-cli', 'SKILL.md'),
1900
- claude: path.join(home, '.claude', 'skills', 'remits-cli', 'SKILL.md'),
1901
- gemini: path.join(home, '.gemini', 'skills', 'remits-cli.md')
2076
+ codex: {
2077
+ skillPath: path.join(home, '.codex', 'skills', 'remits-cli', 'SKILL.md'),
2078
+ referencesDir: path.join(home, '.codex', 'skills', 'remits-cli', SKILL_REFERENCES_DIRNAME)
2079
+ },
2080
+ claude: {
2081
+ skillPath: path.join(home, '.claude', 'skills', 'remits-cli', 'SKILL.md'),
2082
+ referencesDir: path.join(home, '.claude', 'skills', 'remits-cli', SKILL_REFERENCES_DIRNAME)
2083
+ },
2084
+ gemini: {
2085
+ skillPath: path.join(home, '.gemini', 'skills', 'remits-cli.md'),
2086
+ referencesDir: path.join(home, '.gemini', 'skills', 'remits-cli', SKILL_REFERENCES_DIRNAME)
2087
+ }
1902
2088
  };
1903
2089
 
1904
2090
  if (value === 'all') {
@@ -1910,34 +2096,83 @@ function resolveSkillTargets(target) {
1910
2096
  return [[value, targets[value]]];
1911
2097
  }
1912
2098
 
1913
- function buildSkillSourceBody() {
2099
+ function listSkillReferenceSources() {
2100
+ if (!fs.existsSync(SKILL_REFERENCES_SOURCE_DIR)) {
2101
+ return [];
2102
+ }
2103
+ return fs.readdirSync(SKILL_REFERENCES_SOURCE_DIR)
2104
+ .filter((name) => name.endsWith('.md'))
2105
+ .sort();
2106
+ }
2107
+
2108
+ function buildSkillSourceBody(referencesDir) {
1914
2109
  if (!fs.existsSync(SKILL_SOURCE_FILE)) {
1915
2110
  throw new Error('Skill source file not found: ' + SKILL_SOURCE_FILE);
1916
2111
  }
2112
+ return deliverSkillMarkdown(fs.readFileSync(SKILL_SOURCE_FILE, 'utf8'), referencesDir);
2113
+ }
2114
+
2115
+ // Install the reference set beside (or, for gemini, alongside) the skill file, and
2116
+ // prune any reference this CLI version no longer ships. A stale reference is worse
2117
+ // than a missing one: SKILL.md would not name it, but an agent listing the directory
2118
+ // would still find and trust it.
2119
+ function writeSkillReferences(referencesDir, options = {}) {
2120
+ const names = listSkillReferenceSources();
2121
+ const written = [];
2122
+ if (!names.length) {
2123
+ return written;
2124
+ }
1917
2125
 
1918
- let sourceBody = fs.readFileSync(SKILL_SOURCE_FILE, 'utf8');
1919
- sourceBody = sourceBody.replace('{{ACCOUNT_REPO_INDEX_PATH}}', ACCOUNT_REPO_INDEX_FILE);
1920
- return sourceBody;
2126
+ ensureDir(referencesDir);
2127
+ const expected = new Set(names);
2128
+
2129
+ for (const name of names) {
2130
+ const body = deliverSkillMarkdown(
2131
+ fs.readFileSync(path.join(SKILL_REFERENCES_SOURCE_DIR, name), 'utf8'),
2132
+ referencesDir
2133
+ );
2134
+ const targetPath = path.join(referencesDir, name);
2135
+ if (options.overwrite === false && fs.existsSync(targetPath)) {
2136
+ continue;
2137
+ }
2138
+ if (writeFileIfChanged(targetPath, body)) {
2139
+ written.push(targetPath);
2140
+ }
2141
+ }
2142
+
2143
+ try {
2144
+ for (const existing of fs.readdirSync(referencesDir)) {
2145
+ if (existing.endsWith('.md') && !expected.has(existing)) {
2146
+ fs.unlinkSync(path.join(referencesDir, existing));
2147
+ }
2148
+ }
2149
+ } catch (_) {
2150
+ // Best-effort pruning; a stale file never blocks delivery of the current set.
2151
+ }
2152
+
2153
+ return written;
1921
2154
  }
1922
2155
 
1923
2156
  function syncInstalledSkills(target = 'all') {
1924
2157
  const targets = resolveSkillTargets(target);
1925
- const sourceBody = buildSkillSourceBody();
1926
2158
  const summary = {
1927
2159
  updated: [],
1928
2160
  failed: []
1929
2161
  };
1930
2162
 
1931
- for (const [agent, targetPath] of targets) {
2163
+ for (const [agent, spec] of targets) {
1932
2164
  try {
1933
- ensureDir(path.dirname(targetPath));
1934
- if (writeFileIfChanged(targetPath, sourceBody)) {
1935
- summary.updated.push({ agent, path: targetPath });
2165
+ ensureDir(path.dirname(spec.skillPath));
2166
+ if (writeFileIfChanged(spec.skillPath, buildSkillSourceBody(spec.referencesDir))) {
2167
+ summary.updated.push({ agent, path: spec.skillPath });
2168
+ }
2169
+ for (const referencePath of writeSkillReferences(spec.referencesDir)) {
2170
+ summary.updated.push({ agent, path: referencePath });
1936
2171
  }
1937
2172
  } catch (error) {
1938
2173
  summary.failed.push({
1939
2174
  agent,
1940
- path: targetPath,
2175
+ path: spec.skillPath,
1941
2176
  reason: error && error.message ? error.message : String(error)
1942
2177
  });
1943
2178
  }
@@ -1953,19 +2188,24 @@ async function installSkillsCommand(flags) {
1953
2188
 
1954
2189
  const overwrite = flags.overwrite === true || flags.overwrite === 'true';
1955
2190
  const targets = resolveSkillTargets(flags.target || 'all');
1956
- const sourceBody = buildSkillSourceBody();
1957
2191
 
1958
2192
  const installed = [];
1959
2193
  const skipped = [];
1960
2194
 
1961
- for (const [agent, targetPath] of targets) {
1962
- ensureDir(path.dirname(targetPath));
1963
- if (fs.existsSync(targetPath) && !overwrite) {
1964
- skipped.push({ agent, path: targetPath, reason: 'already exists (use --overwrite true)' });
1965
- continue;
2195
+ for (const [agent, spec] of targets) {
2196
+ ensureDir(path.dirname(spec.skillPath));
2197
+ if (fs.existsSync(spec.skillPath) && !overwrite) {
2198
+ skipped.push({ agent, path: spec.skillPath, reason: 'already exists (use --overwrite true)' });
2199
+ } else {
2200
+ atomicWriteFile(spec.skillPath, buildSkillSourceBody(spec.referencesDir));
2201
+ installed.push({ agent, path: spec.skillPath });
2202
+ }
2203
+ // References are refreshed even when the skill file itself was left alone: an
2204
+ // agent reading a customized SKILL.md still needs the reference set it points at.
2205
+ const references = writeSkillReferences(spec.referencesDir);
2206
+ if (references.length) {
2207
+ installed.push({ agent, path: spec.referencesDir + ' (' + references.length + ' reference file(s))' });
1966
2208
  }
1967
- atomicWriteFile(targetPath, sourceBody);
1968
- installed.push({ agent, path: targetPath });
1969
2209
  }
1970
2210
 
1971
2211
  console.log('Skill installation complete.');
@@ -3756,6 +3996,51 @@ function parseToolInput(flags) {
3756
3996
  return {};
3757
3997
  }
3758
3998
 
3999
+ const TOOL_SCOPES = ['self', 'children', 'hierarchy', 'parents'];
4000
+
4001
+ // Hierarchy-scoped discovery flags, folded into the tool's own input.
4002
+ //
4003
+ // They are CONVENIENCE only: every one of them can be written inside --input, and a value already there
4004
+ // wins, because the explicit JSON is what the caller typed for this specific tool. The flags exist so the
4005
+ // overwhelmingly common shape — "search my repo account's children for this id" — does not require
4006
+ // hand-writing JSON.
4007
+ function applyScopeFlags(input, flags) {
4008
+ const merged = input && typeof input === 'object' ? input : {};
4009
+
4010
+ const scope = flags && (flags.scope || flags['tool-scope']);
4011
+ if (scope && merged.scope === undefined) {
4012
+ const normalized = String(scope).trim().toLowerCase();
4013
+ if (!TOOL_SCOPES.includes(normalized)) {
4014
+ throw new Error('Invalid --scope "' + scope + '". Valid values: ' + TOOL_SCOPES.join(', '));
4015
+ }
4016
+ merged.scope = normalized;
4017
+ }
4018
+
4019
+ const targetAccountId = flags && (flags['target-account-id'] || flags.targetAccountId);
4020
+ if (targetAccountId && merged.targetAccountId === undefined) {
4021
+ merged.targetAccountId = String(targetAccountId).trim();
4022
+ }
4023
+
4024
+ const anchorAccountId = flags && (flags['anchor-account-id'] || flags.anchorAccountId);
4025
+ if (anchorAccountId && merged.anchorAccountId === undefined) {
4026
+ merged.anchorAccountId = String(anchorAccountId).trim();
4027
+ }
4028
+
4029
+ const accountIds = flags && (flags['account-ids'] || flags.accountIds);
4030
+ if (accountIds && merged.accountIds === undefined) {
4031
+ const parsed = String(accountIds)
4032
+ .split(',')
4033
+ .map((part) => Number(String(part).trim()))
4034
+ .filter((value) => Number.isFinite(value) && value > 0);
4035
+ if (!parsed.length) {
4036
+ throw new Error('--account-ids expects a comma-separated list of numeric account ids');
4037
+ }
4038
+ merged.accountIds = parsed;
4039
+ }
4040
+
4041
+ return merged;
4042
+ }
4043
+
3759
4044
  async function toolCommand(flags) {
3760
4045
  const cwd = process.cwd();
3761
4046
  ensureLocalState(cwd);
@@ -3774,7 +4059,7 @@ async function toolCommand(flags) {
3774
4059
  const explicitFlagAccountId = Number(flags && flags['account-id']);
3775
4060
  const accountIdExplicit = Number.isFinite(explicitFlagAccountId) && explicitFlagAccountId > 0;
3776
4061
 
3777
- const input = parseToolInput(flags);
4062
+ const input = applyScopeFlags(parseToolInput(flags), flags);
3778
4063
  const baseUrl = flags['base-url'] || session.baseUrl || DEFAULT_BASE_URL;
3779
4064
  const branchName = flags.branch || currentBranch(cwd);
3780
4065
  const dataMode = resolveDataMode(flags, session);
@@ -3840,6 +4125,10 @@ async function toolCommand(flags) {
3840
4125
  token: session.token,
3841
4126
  accountId,
3842
4127
  accountIdExplicit,
4128
+ // The repo/session account, sent as a FIRST-CLASS value rather than left to be inferred from
4129
+ // `accountId`: a read tool may reinterpret its own `input.accountId`, and the scope root — what bounds
4130
+ // what the call may discover — has to survive that. The server validates it; it is never authorization.
4131
+ scopeRootAccountId: accountId,
3843
4132
  branchName,
3844
4133
  workspace: resolveWorkspace(cwd, flags),
3845
4134
  dataMode,
@@ -6074,28 +6363,55 @@ const AGENT_WORKER_MAX_LOG_BYTES = 8 * 1024 * 1024;
6074
6363
  const AGENT_LAUNCHERS = {
6075
6364
  codex: {
6076
6365
  binary: 'codex',
6366
+ // A ticket worker's whole job runs THROUGH the platform: `remits-cli ticket read/accept/progress/
6367
+ // complete`, and every investigation tool. So the sandbox has to let it reach the platform, and by
6368
+ // default it does not — `workspace-write` disables network, and a worker launched without this flag
6369
+ // fails its FIRST command with exit 6 / HTTP 000 while looking, from the outside, like an agent that
6370
+ // simply did not do much. Passed on the command line rather than documented as a config edit so the
6371
+ // supervisor works on a machine nobody has prepared.
6372
+ //
6373
+ // `read-only` is deliberately NOT used for investigate mode, even though it is the tighter sandbox.
6374
+ // It has no network AND no setting that restores it, so a read-only worker cannot read the ticket it
6375
+ // was given, cannot run a diagnostic, and cannot record a finding — the three things investigating
6376
+ // IS. It would then exit 0 having written nothing, which the supervisor reads as a delivered
6377
+ // judgement and un-routes, so the next sweep hands the same ticket to another worker that learns the
6378
+ // same nothing. A loop that reports success is worse than a refusal.
6379
+ //
6380
+ // What enforces the read-only phase instead: the brief's Boundaries section, which names the lease
6381
+ // holder, says which commands not to run, and explains that the shared CHECKOUT is the thing at
6382
+ // risk. Enforcement moved from the sandbox to the instructions deliberately — the sandbox could not
6383
+ // express "may talk to the platform, may not write this directory".
6077
6384
  build: ({ cwd, mode, lastMessageFile }) => ({
6078
6385
  args: [
6079
6386
  'exec',
6080
6387
  '--cd', cwd,
6081
6388
  '--skip-git-repo-check',
6082
- '--sandbox', mode === 'investigate' ? 'read-only' : 'workspace-write',
6389
+ '--sandbox', 'workspace-write',
6390
+ '-c', 'sandbox_workspace_write.network_access=true',
6083
6391
  '--json',
6084
6392
  ...(lastMessageFile ? ['--output-last-message', lastMessageFile] : []),
6085
6393
  '-'
6086
- ]
6394
+ ],
6395
+ // Reported on the worker record so an operator can tell a phase the platform derived from a
6396
+ // sandbox the launcher applied — they are no longer the same thing for codex.
6397
+ sandbox: 'workspace-write' + (mode === 'investigate' ? ' (investigate: boundary is the brief)' : '')
6087
6398
  })
6088
6399
  },
6089
6400
  claude: {
6090
6401
  binary: 'claude',
6402
+ // Same requirement, different mechanism: in `-p` there is no one to answer a permission prompt, so
6403
+ // an un-allowlisted Bash call is DENIED rather than queued. Without this the worker cannot run
6404
+ // remits-cli at all. `plan` mode is avoided for the same reason `read-only` is avoided above.
6091
6405
  build: ({ cwd, mode }) => ({
6092
6406
  args: [
6093
6407
  '-p',
6094
6408
  '--add-dir', cwd,
6095
6409
  '--output-format', 'stream-json',
6096
6410
  '--verbose',
6097
- '--permission-mode', mode === 'investigate' ? 'plan' : 'acceptEdits'
6098
- ]
6411
+ '--permission-mode', 'acceptEdits',
6412
+ '--allowedTools', 'Bash,Read,Grep,Glob,WebFetch' + (mode === 'investigate' ? '' : ',Edit,Write')
6413
+ ],
6414
+ sandbox: 'acceptEdits' + (mode === 'investigate' ? ' (investigate: no Edit/Write tool)' : '')
6099
6415
  })
6100
6416
  },
6101
6417
  gemini: {
@@ -6107,7 +6423,8 @@ const AGENT_LAUNCHERS = {
6107
6423
  '-p', 'Work the support ticket described above, following its instructions exactly.',
6108
6424
  '--approval-mode', mode === 'investigate' ? 'plan' : 'auto_edit',
6109
6425
  '--output-format', 'stream-json'
6110
- ]
6426
+ ],
6427
+ sandbox: mode === 'investigate' ? 'plan' : 'auto_edit'
6111
6428
  })
6112
6429
  }
6113
6430
  };
@@ -6179,7 +6496,31 @@ function resolveWorkerAgent(value, anchor = null) {
6179
6496
  };
6180
6497
  }
6181
6498
 
6182
- /** Where the repository for an account is checked out on THIS machine, or null. */
6499
+ /**
6500
+ * Is this directory a real checkout, rather than something that merely contains an account-info.json?
6501
+ *
6502
+ * <p>The index is built by scanning for `account-info.json`, and that file is also in every zip export
6503
+ * of an account. An unpacked export in `~/Downloads` therefore looks exactly like a checkout, and it
6504
+ * exists — which was the whole of the previous validation.</p>
6505
+ */
6506
+ function isAccountCheckout(directory) {
6507
+ try {
6508
+ return !!directory && fs.existsSync(path.join(directory, '.git'));
6509
+ } catch (_) {
6510
+ return false;
6511
+ }
6512
+ }
6513
+
6514
+ /**
6515
+ * Where the repository for an account is checked out on THIS machine, or null.
6516
+ *
6517
+ * <p>Null when the entry is not a git checkout, and that is the load-bearing part. The caller's next
6518
+ * step on null is the brief telling the worker "no local checkout was resolved — do not guess", which
6519
+ * is the correct outcome; the previous `fs.existsSync` test let an unpacked zip export in `~/Downloads`
6520
+ * satisfy it instead, so the guard never fired and a worker edited, staged and reported success against
6521
+ * a directory with no remote and no history. Nothing about that run looked wrong. Measured on this
6522
+ * machine: account 11 (11 open tickets) and account 2 both resolved that way.</p>
6523
+ */
6183
6524
  function repoDirectoryForAccount(accountId) {
6184
6525
  const wanted = Number(accountId);
6185
6526
  if (!Number.isFinite(wanted) || wanted <= 0) {
@@ -6187,12 +6528,48 @@ function repoDirectoryForAccount(accountId) {
6187
6528
  }
6188
6529
  const entry = listAccountRepoEntries(readAccountRepoIndex())
6189
6530
  .find((candidate) => Number(candidate.accountId) === wanted);
6190
- if (!entry || !entry.directory || !fs.existsSync(entry.directory)) {
6531
+ if (!entry || !entry.directory || !isAccountCheckout(entry.directory)) {
6191
6532
  return null;
6192
6533
  }
6193
6534
  return entry.directory;
6194
6535
  }
6195
6536
 
6537
+ /**
6538
+ * Everything wrong with the local account-repo index, as sentences an operator can act on.
6539
+ *
6540
+ * <p>Reported at `agent serve` rather than discovered one ticket at a time: a supervisor about to work a
6541
+ * production queue should be told up front which accounts it cannot work, because the alternative is
6542
+ * finding out per ticket, hours apart, from a brief that says only "no checkout resolved".</p>
6543
+ */
6544
+ function accountRepoIndexProblems(ownCwd = null) {
6545
+ const problems = [];
6546
+ // The account this session is standing in resolves from its OWN directory, not from the shared index,
6547
+ // so an ambiguity there is not a problem for this supervisor — it is two worktrees working as intended.
6548
+ // Reporting it anyway trains an operator to ignore the warning, and the warning is load-bearing for
6549
+ // every OTHER account, where a silent wrong winner is unrecoverable.
6550
+ const own = ownCwd ? accountCheckoutIdentity(ownCwd) : null;
6551
+ for (const entry of listAccountRepoEntries(readAccountRepoIndex())) {
6552
+ const label = 'account ' + entry.accountId + ' (' + (entry.name || 'unnamed') + ')';
6553
+ if (!fs.existsSync(entry.directory)) {
6554
+ problems.push({ accountId: Number(entry.accountId), severity: 'unusable',
6555
+ message: label + ': the recorded directory no longer exists — ' + entry.directory });
6556
+ } else if (!isAccountCheckout(entry.directory)) {
6557
+ problems.push({ accountId: Number(entry.accountId), severity: 'unusable',
6558
+ message: label + ': ' + entry.directory + ' is not a git checkout (an unpacked export?). ' +
6559
+ 'Tickets for this account will park instead of being worked.' });
6560
+ }
6561
+ if (Array.isArray(entry.ambiguousWith) && entry.ambiguousWith.length &&
6562
+ !(own && own.accountId === Number(entry.accountId))) {
6563
+ problems.push({ accountId: Number(entry.accountId), severity: 'ambiguous',
6564
+ message: label + ': more than one checkout claims this account. Using ' + entry.directory +
6565
+ '; also found ' + entry.ambiguousWith.join(', ') +
6566
+ '. Set the right one with `remits-cli agent repos --account-id ' + entry.accountId +
6567
+ ' --directory <path>`.' });
6568
+ }
6569
+ }
6570
+ return problems;
6571
+ }
6572
+
6196
6573
  /**
6197
6574
  * The working directory a ticket should be worked in.
6198
6575
  *
@@ -6201,13 +6578,27 @@ function repoDirectoryForAccount(accountId) {
6201
6578
  * neither is checked out here the answer is null, and the brief says so explicitly instead of
6202
6579
  * letting the worker start in whatever directory the supervisor happened to inherit.
6203
6580
  */
6204
- function resolveTicketWorkingDirectory(ticket) {
6581
+ function resolveTicketWorkingDirectory(ticket, ownCwd = null) {
6205
6582
  const facts = (ticket && ticket.briefFacts) || {};
6206
6583
  const candidates = [
6207
6584
  facts.repoAccountId,
6208
6585
  ticket && ticket.implementationAccountId,
6209
6586
  ticket && ticket.accountId
6210
- ];
6587
+ ].map((value) => Number(value)).filter((value) => Number.isFinite(value) && value > 0);
6588
+
6589
+ // THIS supervisor's own checkout wins, when it is a checkout of one of the candidate accounts.
6590
+ //
6591
+ // The account-repo index is a single global map keyed by accountId, and `agent serve` writes its own
6592
+ // cwd into it — so two supervisors serving two git WORKTREES of one account fight over one entry, and
6593
+ // the second to start silently repoints the first's future workers into its own tree. That is the
6594
+ // shape worktrees exist to prevent: a supervisor must launch its workers where IT is, not where the
6595
+ // machine's last-registered session happened to be. The index stays the answer for every account this
6596
+ // session is not standing in, which is the normal case.
6597
+ const own = ownCwd ? accountCheckoutIdentity(ownCwd) : null;
6598
+ if (own && candidates.includes(own.accountId)) {
6599
+ return own.directory;
6600
+ }
6601
+
6211
6602
  for (const candidate of candidates) {
6212
6603
  const dir = repoDirectoryForAccount(candidate);
6213
6604
  if (dir) {
@@ -6217,6 +6608,30 @@ function resolveTicketWorkingDirectory(ticket) {
6217
6608
  return null;
6218
6609
  }
6219
6610
 
6611
+ /**
6612
+ * Which account this directory is a checkout OF, read from the directory itself.
6613
+ *
6614
+ * Deliberately not a lookup in the shared index: the whole point is to answer for one process without
6615
+ * consulting a map another process can overwrite. Null when the directory is not a real checkout — an
6616
+ * unpacked zip export carries `account-info.json` too, which is exactly the trap `isAccountCheckout`
6617
+ * exists to close.
6618
+ */
6619
+ function accountCheckoutIdentity(directory) {
6620
+ try {
6621
+ if (!directory || !isAccountCheckout(directory)) {
6622
+ return null;
6623
+ }
6624
+ const info = loadAccountInfo(directory);
6625
+ const accountId = Number(info && accountIdFromAccountInfo(info));
6626
+ if (!Number.isFinite(accountId) || accountId <= 0) {
6627
+ return null;
6628
+ }
6629
+ return { accountId, directory: path.resolve(directory) };
6630
+ } catch (_) {
6631
+ return null;
6632
+ }
6633
+ }
6634
+
6220
6635
  /**
6221
6636
  * Turn a worker's streamed output into something an operator can read in one line.
6222
6637
  *
@@ -6665,6 +7080,35 @@ async function agentServeCommand(flags) {
6665
7080
  const mode = String(flags.mode || (flagEnabled(flags['investigate-only']) ? 'investigate' : 'edit'))
6666
7081
  .toLowerCase() === 'investigate' ? 'investigate' : 'edit';
6667
7082
 
7083
+ // Say what this supervisor CANNOT work, before it starts rather than one ticket at a time. A worker
7084
+ // pointed at an account with no usable checkout parks correctly, but the operator only learns that
7085
+ // from a brief they may never read, hours later, on one ticket. `--strict-repos` turns it into a
7086
+ // refusal for anyone who would rather not start a production run with known holes.
7087
+ try {
7088
+ updateAccountRepoIndex(process.cwd());
7089
+ discoverAccountRepos();
7090
+ } catch (_) { /* best-effort; the checks below still report what the index does say */ }
7091
+ const problems = accountRepoIndexProblems(process.cwd());
7092
+ if (problems.length) {
7093
+ console.log('');
7094
+ console.log('Account repository index — ' + problems.length + ' issue(s) to know about before serving:');
7095
+ for (const problem of problems) {
7096
+ console.log(' [' + problem.severity + '] ' + problem.message);
7097
+ }
7098
+ console.log(' Tickets for an "unusable" account are parked with an explanation, never worked in the');
7099
+ console.log(' wrong directory. Fix with: remits-cli agent repos --account-id <id> --directory <path>');
7100
+ const ownHere = accountCheckoutIdentity(process.cwd());
7101
+ if (ownHere) {
7102
+ console.log(' Account ' + ownHere.accountId + ' is exempt: this session serves it from its own directory');
7103
+ console.log(' (' + ownHere.directory + '), so a second worktree elsewhere cannot redirect these workers.');
7104
+ }
7105
+ console.log('');
7106
+ if (flagEnabled(flags['strict-repos'])) {
7107
+ throw new Error('Refusing to serve with ' + problems.length +
7108
+ ' account-repository issue(s). Resolve them, or drop --strict-repos to serve anyway.');
7109
+ }
7110
+ }
7111
+
6668
7112
  return agentRegisterCommand(flags, {
6669
7113
  serve: {
6670
7114
  workerAgent,
@@ -6876,6 +7320,54 @@ function clearWorkerAttempts(agentId, ticketId) {
6876
7320
  }
6877
7321
  }
6878
7322
 
7323
+ /**
7324
+ * The worker's closing summary, recorded on the ticket by the SUPERVISOR.
7325
+ *
7326
+ * <p>A worker is supposed to record what it established itself, with `remits-cli ticket progress`. Most
7327
+ * do. The ones that do not are exactly the runs worth hearing from: a worker that hit its context limit,
7328
+ * that crashed after investigating, or that could not reach the platform at all. Their conclusion was
7329
+ * captured to disk the whole time (`codex exec --output-last-message`) and thrown away, so a ticket came
7330
+ * back to the queue with nothing on it and the next worker rediscovered the same thing.</p>
7331
+ *
7332
+ * <p>Posted only when the run added no worklog entry of its own, so a worker that reported properly is
7333
+ * not duplicated. Marked `worker_summary` rather than `investigation` so a reader can tell a finding the
7334
+ * worker chose to record from a transcript tail the supervisor salvaged.</p>
7335
+ */
7336
+ async function postWorkerSummary(context, agentId, ticket, summaryFile, options = {}) {
7337
+ let text = '';
7338
+ try {
7339
+ text = fs.readFileSync(summaryFile, 'utf8').trim();
7340
+ } catch (_) {
7341
+ return false; // codex only writes it on a clean finish, and the other launchers never do.
7342
+ }
7343
+ if (!text) {
7344
+ return false;
7345
+ }
7346
+ // The worklog is read by people and by the next worker's brief; a whole transcript would bury both.
7347
+ const MAX = 4000;
7348
+ const body = text.length > MAX ? (text.slice(0, MAX) + '\n\n…truncated; full run: ' + options.logFile) : text;
7349
+ try {
7350
+ await buildAxios(context.baseUrl, context.session.token, 20000)
7351
+ .post('/cli/ticket', {
7352
+ action: 'progress',
7353
+ ticketId: ticket.ticketId || ticket.id,
7354
+ accountId: Number(ticket.accountId) || undefined,
7355
+ dataMode: context.dataMode,
7356
+ agentId,
7357
+ category: 'worker_summary',
7358
+ summary: 'Autonomous worker exited without recording a finding; this is its closing summary.',
7359
+ details: body,
7360
+ nextStep: options.nextStep || null
7361
+ });
7362
+ return true;
7363
+ } catch (err) {
7364
+ appendGlobalActivityLog('agent', 'worker.summary_post_failed', {
7365
+ agentId, ticketId: ticket.ticketId || ticket.id, error: describeError(err)
7366
+ }, 'error');
7367
+ return false;
7368
+ }
7369
+ }
7370
+
6879
7371
  /** Drop a claim on the platform. Best-effort: the claim expires on its own if this never lands. */
6880
7372
  async function releaseTicketClaim(context, agentId, ticket, options = {}) {
6881
7373
  try {
@@ -6928,7 +7420,7 @@ function spawnTicketWorker(context, ctx, ticket) {
6928
7420
  return { pid: null, done: Promise.resolve({ ticketId, ok: false, reason: 'unsupported_agent' }) };
6929
7421
  }
6930
7422
 
6931
- const workingDirectory = resolveTicketWorkingDirectory(ticket);
7423
+ const workingDirectory = resolveTicketWorkingDirectory(ticket, ctx && ctx.cwd);
6932
7424
  // The brief is rendered server-side without the local path (the platform cannot know it), so the
6933
7425
  // resolved directory is appended here. When nothing resolved, the server-rendered brief already
6934
7426
  // tells the worker to find it rather than guess — do not paper over that with a default.
@@ -6999,6 +7491,11 @@ function spawnTicketWorker(context, ctx, ticket) {
6999
7491
  workspace: workerWorkspace,
7000
7492
  attempt,
7001
7493
  agentType,
7494
+ // The sandbox the launcher actually applied. Kept beside the phase because for codex they are no
7495
+ // longer the same fact: an investigating worker still runs workspace-write (it needs the network to
7496
+ // report at all), and its boundary is the brief. An operator reading "investigating" must be able to
7497
+ // see that.
7498
+ sandbox: built.sandbox || null,
7002
7499
  startedAt: new Date().toISOString(),
7003
7500
  workingDirectory: workingDirectory || null,
7004
7501
  accountId: ticket.accountId || null,
@@ -7081,6 +7578,22 @@ function spawnTicketWorker(context, ctx, ticket) {
7081
7578
  });
7082
7579
 
7083
7580
  const row = (result && result.ticket) || {};
7581
+
7582
+ // Did this run leave anything on the record? If not, salvage its closing summary before the ticket
7583
+ // goes back to the queue — otherwise the next worker starts from exactly where this one did.
7584
+ // Compared against the count we were HANDED at spawn, so a worker that reported properly (or that
7585
+ // asked a question, or completed) is never duplicated.
7586
+ const worklogBefore = Number(ticket.worklogCount || 0);
7587
+ const worklogAfter = Number(row.worklogCount != null ? row.worklogCount : worklogBefore);
7588
+ if (worklogAfter <= worklogBefore && !['resolved', 'closed'].includes(String(row.status || ''))) {
7589
+ await postWorkerSummary(context, agentId, { ticketId, accountId: ticket.accountId }, lastMessageFile, {
7590
+ logFile,
7591
+ nextStep: giveUp
7592
+ ? 'This worker failed ' + attempt + ' time(s) and the ticket was returned to the queue.'
7593
+ : null
7594
+ });
7595
+ }
7596
+
7084
7597
  const terminal = ['resolved', 'closed'].includes(String(row.status || ''));
7085
7598
  if (terminal || giveUp) {
7086
7599
  clearWorkerAttempts(agentId, ticketId);
@@ -7397,6 +7910,21 @@ async function agentWorkCommand(flags) {
7397
7910
  */
7398
7911
  async function ticketCommand(flags, subcommand) {
7399
7912
  const action = String(subcommand || 'read').toLowerCase();
7913
+
7914
+ // `queue` and `create` are the two verbs with no ticket to act on, so they are dispatched before the
7915
+ // id requirement rather than being forced to invent one.
7916
+ if (action === 'queue' || action === 'list' || action === 'ls') {
7917
+ return ticketQueueCommand(flags);
7918
+ }
7919
+ if (action === 'create' || action === 'new' || action === 'raise') {
7920
+ return ticketCreateCommand(flags);
7921
+ }
7922
+ // Bulk reclaim scans a queue rather than acting on one id, so it is dispatched here too. With
7923
+ // --ticket it falls through to the single-ticket path below like any other verb.
7924
+ if (action === 'reclaim' && !flags.ticket && !flags['ticket-id']) {
7925
+ return ticketReclaimSweepCommand(flags);
7926
+ }
7927
+
7400
7928
  const ticketId = String(flags.ticket || flags['ticket-id'] || flags._?.[2] || process.env.REMITS_SUPPORT_TICKET_ID || '').trim();
7401
7929
  if (!ticketId) {
7402
7930
  printTicketHelp();
@@ -7441,7 +7969,9 @@ async function ticketCommand(flags, subcommand) {
7441
7969
  summary: flags.summary || flags.note,
7442
7970
  question: flags.question,
7443
7971
  context: flags.context,
7444
- to: flags.to,
7972
+ // Recipients are a list on the platform side (message.to / deliver.to). A single value still works,
7973
+ // so `--to a@x.test` and `--to a@x.test,b@x.test` both do what they look like they do.
7974
+ to: flags.to ? String(flags.to).split(',').map((v) => v.trim()).filter(Boolean) : undefined,
7445
7975
  message: flags.message || flags.answer,
7446
7976
  route: flags.route === 'false' ? false : undefined,
7447
7977
  details: flags.details,
@@ -7457,6 +7987,26 @@ async function ticketCommand(flags, subcommand) {
7457
7987
  size: planningFlagValue(flags.size),
7458
7988
  blockedBy: planningFlagValue(firstDefined(flags['blocked-by'], flags.blockedBy)),
7459
7989
  workstream: planningFlagValue(flags.workstream),
7990
+ tags: flags.tags ? String(flags.tags).split(',').map((v) => v.trim()).filter(Boolean) : undefined,
7991
+ label: flags.label,
7992
+ url: flags.url,
7993
+ body: flags.body,
7994
+ role: flags.role,
7995
+ email: flags.email,
7996
+ key: flags.key,
7997
+ value: flags.value,
7998
+ // Conversation and outbox. `--message` is shared with `answer`/`note`, which is deliberate: it is
7999
+ // always "the text", and the ACTION decides who ends up reading it.
8000
+ channel: flags.channel,
8001
+ subject: flags.subject,
8002
+ visibility: flags.visibility,
8003
+ direction: flags.direction,
8004
+ template: flags.template,
8005
+ cc: flags.cc ? String(flags.cc).split(',').map((v) => v.trim()).filter(Boolean) : undefined,
8006
+ deliveryId: flags['delivery-id'] || flags.deliveryId || undefined,
8007
+ idempotencyKey: flags['idempotency-key'] || flags.idempotencyKey || undefined,
8008
+ providerMessageId: flags['provider-message-id'] || undefined,
8009
+ reason: flags.reason,
7460
8010
  // Lease operations act as the AGENT, not the user: the lease is held by a worker session, and a
7461
8011
  // human's email is not a thing that can hold one. Inside a worker these come from the environment
7462
8012
  // the supervisor set, so the commands need no flags.
@@ -7495,7 +8045,7 @@ async function ticketCommand(flags, subcommand) {
7495
8045
  return;
7496
8046
  }
7497
8047
 
7498
- if (flagEnabled(flags.json) || action === 'read') {
8048
+ if (flagEnabled(flags.json) || action === 'read' || action === 'deliveries') {
7499
8049
  console.log(JSON.stringify(response, null, 2));
7500
8050
  return;
7501
8051
  }
@@ -7537,6 +8087,19 @@ async function ticketCommand(flags, subcommand) {
7537
8087
  const ticket = (response && response.ticket) || {};
7538
8088
  console.log(action + ' ok — ticket ' + ticketId + ' is now [' + (ticket.status || '?') + ']' +
7539
8089
  (ticket.assignedTo ? ', owned by ' + ticket.assignedTo : ', unassigned'));
8090
+ // Say what was actually written. Without this, `message`, `note`, `deliver` and `participant` all
8091
+ // printed the same status line, so the only feedback distinguishing "told the customer" from "left an
8092
+ // internal note" was the word the caller had just typed.
8093
+ if (action === 'message') {
8094
+ console.log(' public message added (' + (ticket.messageCount || '?') + ' on the ticket) — visible to the requester.');
8095
+ console.log(' This did NOT send anything. To have it delivered: remits-cli ticket deliver --channel email --to <who>');
8096
+ }
8097
+ if (action === 'note') console.log(' internal note added — the requester does not see it.');
8098
+ if (action === 'participant') console.log(' participants: ' + (ticket.participantCount || '?'));
8099
+ if (action === 'deliver') {
8100
+ console.log(' delivery queued (' + (ticket.deliveryCount || '?') + ' on the ticket). An account-owned rule or');
8101
+ console.log(' scheduled Action sends it. Check with: remits-cli ticket deliveries --ticket ' + ticketId);
8102
+ }
7540
8103
  if (action === 'planning' || action === 'plan') {
7541
8104
  const planning = [
7542
8105
  ticket.workstream ? 'workstream=' + ticket.workstream : '',
@@ -7557,6 +8120,229 @@ async function ticketCommand(flags, subcommand) {
7557
8120
  }
7558
8121
  }
7559
8122
 
8123
+ /**
8124
+ * The ticket queue — what else is open, and what it looks like as a whole.
8125
+ *
8126
+ * <p><b>Why this had to exist.</b> The worker brief is emphatic that `remits-cli ticket` is the surface to
8127
+ * use, and that an account's own ticket tooling must not be reached for: it belongs to one account, may not
8128
+ * exist on this platform, and refuses tickets other accounts own. That advice was right and the surface was
8129
+ * incomplete — it could act on ONE ticket whose id it was handed, and could not see anything else. So a
8130
+ * worker could not tell that the ticket it was given is one of eleven instances of the same fix, could not
8131
+ * check whether the defect it just found is already filed, and could not triage at all. The server side
8132
+ * already existed in full (`/cli/tickets`, the same `queue(...)` the operator UI reads); its only consumer
8133
+ * was the local dashboard.</p>
8134
+ */
8135
+ async function ticketQueueCommand(flags) {
8136
+ const local = listLocalAgents()[0] || null;
8137
+ const context = agentSessionFor(flags, local);
8138
+ const accountId = Number(flags['account-id'] || flags.accountId || context.accountId);
8139
+ if (!Number.isFinite(accountId) || accountId <= 0) {
8140
+ throw new Error('ticket queue needs --account-id (or run it from an account repo).');
8141
+ }
8142
+
8143
+ const csv = (value) => (value ? String(value).split(',').map((v) => v.trim()).filter(Boolean) : undefined);
8144
+ const response = await buildAxios(context.baseUrl, context.session.token, 30000)
8145
+ .post('/cli/tickets', {
8146
+ accountId,
8147
+ dataMode: context.dataMode,
8148
+ statuses: csv(flags.status || flags.statuses),
8149
+ priorities: csv(flags.priority || flags.priorities),
8150
+ types: csv(flags.type || flags.types),
8151
+ sources: csv(flags.source || flags.sources),
8152
+ workstream: flags.workstream,
8153
+ assignedTo: flags['assigned-to'] || flags.assignedTo,
8154
+ routedAgentId: flags['routed-to'] || flags.routedAgentId,
8155
+ unrouted: flagEnabled(flags.unrouted) || undefined,
8156
+ unassigned: flagEnabled(flags.unassigned) || undefined,
8157
+ // "Parked on a human" cannot be asked for with a status filter: `ask` parks a ticket in
8158
+ // pending_review, which also means "done, review me".
8159
+ awaitingResponse: flagEnabled(flags['awaiting-response'] || flags.awaitingResponse) || undefined,
8160
+ plannedIn: flags['planned-in'] || flags.plannedIn,
8161
+ boardStage: flags['board-stage'] || flags.boardStage,
8162
+ blockedBy: flags['blocked-by'] || flags.blockedBy,
8163
+ search: flags.search || flags.q,
8164
+ sortBy: flags['sort-by'] || flags.sortBy,
8165
+ sortDirection: flags['sort-direction'] || flags.sortDirection,
8166
+ includeChildren: flags['include-children'] === 'false' ? false : undefined,
8167
+ limit: parsePositiveInt(flags.limit, 25)
8168
+ }).then((r) => r.data);
8169
+
8170
+ if (flagEnabled(flags.json)) {
8171
+ console.log(JSON.stringify(response, null, 2));
8172
+ return;
8173
+ }
8174
+
8175
+ const tickets = (response && response.tickets) || [];
8176
+ const counts = (response && response.counts) || {};
8177
+ if (!tickets.length) {
8178
+ console.log('No tickets match. (' + (response && response.dataMode) + ' lane, account ' + accountId + ')');
8179
+ return;
8180
+ }
8181
+ console.log(String(response.totalCount != null ? response.totalCount : tickets.length) +
8182
+ ' ticket(s) — ' + (counts.unrouted || 0) + ' unrouted, ' + (counts.unassigned || 0) + ' unassigned' +
8183
+ ', ' + (counts.awaitingResponse || 0) + ' waiting on a human' +
8184
+ ' [' + (response.dataMode || '?') + ' lane]');
8185
+ console.log('');
8186
+ // Board position is printed only when some ticket in this queue actually has one. An always-on column
8187
+ // would be blank for every account that does not run a board, and a blank column reads as missing data.
8188
+ const showsBoard = tickets.some((t) => t.boardStage || t.plannedIn || t.blockedBy);
8189
+ for (const t of tickets) {
8190
+ // The repo account, not just the owning account: it is what the edit lease excludes on, so it is
8191
+ // what tells a reader which of these can be worked at the same time.
8192
+ const repo = t.implementationAccountId || t.accountId;
8193
+ const who = t.routedAgentLabel || t.routedAgentId || t.assignedTo || '—';
8194
+ console.log(' ' + String(t.ticketId || t.id).padStart(6) + ' ' +
8195
+ String(t.status || '').padEnd(14) + String(t.priority || '').padEnd(9) +
8196
+ 'repo ' + String(repo || '?').padEnd(6) +
8197
+ String(who).slice(0, 18).padEnd(20) + String(t.subject || '').slice(0, 58));
8198
+ // A second line rather than more columns: these are the account's own labels, of unbounded length,
8199
+ // and truncating `awaiting_customer_confirmation` to fit a column makes it unrecognisable.
8200
+ const marks = [
8201
+ t.awaitingResponseSince ? 'WAITING ON A HUMAN' : '',
8202
+ showsBoard && t.boardStage ? 'stage ' + t.boardStage : '',
8203
+ showsBoard && t.plannedIn ? 'in ' + t.plannedIn : '',
8204
+ showsBoard && t.blockedBy ? 'BLOCKED BY ' + t.blockedBy : ''
8205
+ ].filter(Boolean);
8206
+ if (marks.length) console.log(' '.repeat(10) + marks.join(' '));
8207
+ }
8208
+ if (response.totalCount > tickets.length) {
8209
+ console.log('');
8210
+ console.log(' showing ' + tickets.length + ' of ' + response.totalCount + ' — raise --limit to see more');
8211
+ }
8212
+ }
8213
+
8214
+ /**
8215
+ * Find tickets an agent took and abandoned, and hand them back to the queue.
8216
+ *
8217
+ * <p><b>Dry by default.</b> This takes work away from a holder on a judgement about staleness, across a
8218
+ * whole account tree, in one command — so the default is to show what it WOULD do. `--apply` is the
8219
+ * operator saying yes. The platform re-checks every row against the document before acting, so a stale
8220
+ * listing cannot cause a wrong reclaim even if this list is minutes old.</p>
8221
+ */
8222
+ async function ticketReclaimSweepCommand(flags) {
8223
+ const local = listLocalAgents()[0] || null;
8224
+ const context = agentSessionFor(flags, local);
8225
+ const accountId = Number(flags['account-id'] || flags.accountId || context.accountId);
8226
+ if (!Number.isFinite(accountId) || accountId <= 0) {
8227
+ throw new Error('ticket reclaim needs --account-id (or --ticket ID for a single ticket).');
8228
+ }
8229
+ const staleHours = parsePositiveInt(flags['stale-hours'] || flags.staleHours, 24);
8230
+ const apply = flagEnabled(flags.apply);
8231
+
8232
+ const queue = await buildAxios(context.baseUrl, context.session.token, 30000)
8233
+ .post('/cli/tickets', {
8234
+ accountId, dataMode: context.dataMode,
8235
+ statuses: ['accepted', 'in_progress'], limit: parsePositiveInt(flags.limit, 200)
8236
+ }).then((r) => r.data);
8237
+
8238
+ const cutoff = Date.now() - staleHours * 3600000;
8239
+ // A local pre-filter only, to keep the output short and avoid asking about obviously-live rows. The
8240
+ // platform owns the actual decision and will refuse anything that does not qualify.
8241
+ const candidates = ((queue && queue.tickets) || []).filter((t) => {
8242
+ const holder = t.assignedTo || t.routedAgentId;
8243
+ if (!holder || String(holder).includes('@')) return false;
8244
+ if (t.awaitingResponseSince) return false;
8245
+ const touched = Date.parse(t.updatedAt || t.claimedAt || t.assignedAt || '');
8246
+ return Number.isFinite(touched) && touched < cutoff;
8247
+ });
8248
+
8249
+ if (!candidates.length) {
8250
+ console.log('No abandoned tickets under account ' + accountId + ' (agent-held, untouched for ' +
8251
+ staleHours + 'h). ' + (((queue && queue.tickets) || []).length) + ' accepted/in_progress ticket(s) checked.');
8252
+ return;
8253
+ }
8254
+
8255
+ console.log((apply ? 'Reclaiming ' : 'Would reclaim ') + candidates.length + ' abandoned ticket(s) — ' +
8256
+ 'agent-held, no live session, untouched for ' + staleHours + 'h:');
8257
+ console.log('');
8258
+ let done = 0;
8259
+ for (const t of candidates) {
8260
+ const id = String(t.ticketId || t.id);
8261
+ const line = ' ' + id.padStart(6) + ' ' + String(t.status || '').padEnd(13) +
8262
+ String(t.assignedTo || t.routedAgentId || '').slice(0, 16).padEnd(18) +
8263
+ String(t.subject || '').slice(0, 52);
8264
+ if (!apply) {
8265
+ console.log(line);
8266
+ continue;
8267
+ }
8268
+ try {
8269
+ await buildAxios(context.baseUrl, context.session.token, 30000)
8270
+ .post('/cli/ticket', {
8271
+ action: 'reclaim', ticketId: id, accountId: Number(t.accountId) || undefined,
8272
+ dataMode: context.dataMode, staleHours, reason: flags.reason || undefined
8273
+ });
8274
+ done += 1;
8275
+ console.log(line + ' -> open');
8276
+ } catch (err) {
8277
+ const body = err && err.response && err.response.data;
8278
+ // A refusal is the platform re-checking against the document and disagreeing with this listing.
8279
+ // That is the guard working, so it is reported per ticket rather than failing the run.
8280
+ console.log(line + ' -> refused: ' + ((body && body.message) || describeError(err)).slice(0, 90));
8281
+ }
8282
+ }
8283
+ console.log('');
8284
+ console.log(apply
8285
+ ? (done + ' reclaimed and back in the queue; an idle supervisor will sweep them.')
8286
+ : 'Dry run. Re-run with --apply to reclaim these.');
8287
+ }
8288
+
8289
+ /**
8290
+ * Raise a ticket.
8291
+ *
8292
+ * <p>The verb a worker needs when it finds a SECOND problem. Without it the only options were to widen the
8293
+ * ticket it was given until the record no longer described one piece of work, or to drop the finding.</p>
8294
+ */
8295
+ async function ticketCreateCommand(flags) {
8296
+ const local = listLocalAgents()[0] || null;
8297
+ const context = agentSessionFor(flags, local);
8298
+ const accountId = Number(flags['account-id'] || flags.accountId || context.accountId);
8299
+ if (!Number.isFinite(accountId) || accountId <= 0) {
8300
+ throw new Error('ticket create needs --account-id (or run it from an account repo).');
8301
+ }
8302
+ if (!flags.subject) {
8303
+ throw new Error('ticket create needs --subject "..." and --type defect|question|task|incident|enhancement.');
8304
+ }
8305
+
8306
+ let response;
8307
+ try {
8308
+ response = await buildAxios(context.baseUrl, context.session.token, 30000)
8309
+ .post('/cli/ticketCreate', {
8310
+ accountId,
8311
+ dataMode: context.dataMode,
8312
+ subject: flags.subject,
8313
+ description: flags.description || flags.body,
8314
+ type: flags.type || 'defect',
8315
+ priority: flags.priority,
8316
+ source: flags.source,
8317
+ workstream: flags.workstream,
8318
+ affectedComponent: flags['affected-component'] || flags.affectedComponent,
8319
+ implementationAccountId: flags['implementation-account-id'] || flags.implementationAccountId,
8320
+ // A deterministic key makes this get-or-create, which is what stops a swept fleet raising the
8321
+ // same follow-up once per worker.
8322
+ referenceId: flags['reference-id'] || flags.referenceId,
8323
+ tags: flags.tags ? String(flags.tags).split(',').map((v) => v.trim()).filter(Boolean) : undefined,
8324
+ agentId: flags['agent-id'] || process.env.REMITS_AGENT_ID || undefined
8325
+ }).then((r) => r.data);
8326
+ } catch (err) {
8327
+ const body = err && err.response && err.response.data;
8328
+ if (body && body.message) {
8329
+ console.error('Refused: ' + body.message);
8330
+ process.exitCode = 1;
8331
+ return;
8332
+ }
8333
+ throw err;
8334
+ }
8335
+
8336
+ if (flagEnabled(flags.json)) {
8337
+ console.log(JSON.stringify(response, null, 2));
8338
+ return;
8339
+ }
8340
+ const t = (response && response.ticket) || {};
8341
+ console.log('created ticket ' + response.ticketId + ' on account ' + response.accountId +
8342
+ ' [' + (t.status || 'open') + ']');
8343
+ console.log(' ' + (t.subject || flags.subject));
8344
+ }
8345
+
7560
8346
  /** The account a ticket belongs to, so routing does not make the caller look it up by hand. */
7561
8347
  async function resolveTicketAccountId(context, ticketId) {
7562
8348
  const read = await buildAxios(context.baseUrl, context.session.token, 20000)
@@ -7573,6 +8359,20 @@ async function resolveTicketAccountId(context, ticketId) {
7573
8359
  function printTicketHelp() {
7574
8360
  console.log('Usage: remits-cli ticket <action> --ticket ID');
7575
8361
  console.log('');
8362
+ console.log(' queue [--account-id ID] [--status open,accepted] [--unrouted] [--search "..."]');
8363
+ console.log(' What else is open. Read it BEFORE deciding your ticket is unique — the same fix often');
8364
+ console.log(' appears a dozen times, and the "repo" column is the account the edit lease excludes');
8365
+ console.log(' on, so it also tells you which of these can be worked at the same time.');
8366
+ console.log(' Also: --priority --type --workstream --assigned-to --unassigned --board-stage');
8367
+ console.log(' --planned-in --blocked-by --sort-by --sort-direction --limit --json');
8368
+ console.log(' --awaiting-response lists only tickets parked on a human. A status filter cannot');
8369
+ console.log(' ask that: `ask` parks a ticket in pending_review, which also means "done, review me".');
8370
+ console.log(' create --subject "..." --type defect|question|task|incident|enhancement');
8371
+ console.log(' Raise a ticket. Use it when you find a SECOND problem while working one — file it');
8372
+ console.log(' rather than widening the ticket you were given. [--priority --description --tags');
8373
+ console.log(' --workstream --affected-component --implementation-account-id --reference-id]');
8374
+ console.log(' --reference-id makes it get-or-create, so a re-run does not duplicate it.');
8375
+ console.log('');
7576
8376
  console.log(' read The full ticket record, as JSON.');
7577
8377
  console.log(' where WHERE this work is happening: repository account,');
7578
8378
  console.log(' checkout, branch, staging workspace lane, the live edit lease and who holds it, the');
@@ -7584,13 +8384,39 @@ function printTicketHelp() {
7584
8384
  console.log(' complete --resolution "what you found and did"');
7585
8385
  console.log(' ask --question "..." [--context "..."] [--to WHO]');
7586
8386
  console.log(' Put a decision to a human and park. Use it when the CHOICE is not yours.');
7587
- console.log(' reply --message "..." [--route false]');
7588
- console.log(' Answer the open question. By default this re-routes the ticket, so a fresh worker');
7589
- console.log(' resumes with your answer already in its brief.');
8387
+ console.log(' answer --message "..." [--route false] (alias: reply)');
8388
+ console.log(' Answer the open question a previous run asked. By default this re-routes the ticket,');
8389
+ console.log(' so a fresh worker resumes with your answer already in its brief. This is NOT how you');
8390
+ console.log(' talk to the requester — that is `message`. (`reply` means the conversational one in');
8391
+ console.log(' some account tools, so this surface teaches `answer` and keeps `reply` as an alias.)');
8392
+ console.log(' message --message "..." [--to a@x,b@y] [--subject "..."] [--channel email]');
8393
+ console.log(' A PUBLIC conversation entry the requester reads. Writing it does not send it:');
8394
+ console.log(' the record is vendor-agnostic and the account\'s own rule chooses the transport.');
8395
+ console.log(' deliver --channel email --to a@x [--template T] [--subject "..."] [--idempotency-key K]');
8396
+ console.log(' Ask for that entry to actually be SENT. It records an outbox request; an account-owned');
8397
+ console.log(' rule or scheduled Action delivers it and marks the result. Use it when a ticket is');
8398
+ console.log(' waiting on somebody who is not watching the queue.');
8399
+ console.log(' deliveries [--channel email] What is queued to go out, and what already went.');
8400
+ console.log(' delivered --delivery-id ID | delivery-failed --delivery-id ID --reason "..."');
7590
8401
  console.log(' release [--notes "..."] Hand it back to the queue.');
8402
+ console.log(' reclaim [--ticket ID | --account-id ID] [--stale-hours 24] [--apply]');
8403
+ console.log(' OPERATOR: take a ticket back from an agent that accepted it and vanished. Such a');
8404
+ console.log(' ticket is invisible to the sweep (it is assigned, so nothing offers it to anyone).');
8405
+ console.log(' Never touches a ticket owned by a person, holding a live claim, or awaiting a reply.');
8406
+ console.log(' Without --ticket it scans the account tree and is a DRY RUN until --apply.');
7591
8407
  console.log(' reopen | assign --assignee EMAIL');
7592
8408
  console.log(' route --agent AGENT_ID Hand it to a registered agent session.');
7593
8409
  console.log(' unroute Clear its routing, e.g. when that session is gone.');
8410
+ console.log(' tag --tags a,b Mark it. The cheapest way to make a cluster of');
8411
+ console.log(' near-identical tickets visible as one cluster to whoever looks next.');
8412
+ console.log(' artifact --type TYPE --label "..." [--url U | --content "..."]');
8413
+ console.log(' Attach evidence — a log, a query result, a repro. Better than pasting it into a');
8414
+ console.log(' worklog summary, where it is neither typed nor retrievable.');
8415
+ console.log(' note --message "..." An INTERNAL note. The next worker and a reviewer');
8416
+ console.log(' see it; the requester does not. Use `message` for anything they should read.');
8417
+ console.log(' participant --email E [--role watcher|requester|agent]');
8418
+ console.log(' field --key K --value V Write the ORGANIZATION\'s own field on the ticket,');
8419
+ console.log(' outside the platform\'s bounded planning slots. Whatever your account models.');
7594
8420
  console.log(' planning [--workstream V] [--planned-in V] [--board-stage V] [--rank N] [--size V] [--blocked-by V]');
7595
8421
  console.log(' Update organization-owned planning fields without changing platform lifecycle status.');
7596
8422
  console.log(' Pass a flag with no value to CLEAR that slot, e.g. --blocked-by');
@@ -7773,6 +8599,104 @@ async function agentListCommand(flags) {
7773
8599
  }
7774
8600
  }
7775
8601
 
8602
+ /**
8603
+ * Inspect and correct the local account-repository index — which checkout a ticket for an account is
8604
+ * worked in.
8605
+ *
8606
+ * <p>This index is the one fact the platform cannot supply: it knows the ticket and the account, and
8607
+ * only this machine knows where that account is checked out. It is built by scanning for
8608
+ * `account-info.json`, which is also present in every unpacked zip export, so it can and does acquire
8609
+ * entries that are not checkouts at all. There was no way to see it, and no way to correct it.</p>
8610
+ */
8611
+ async function agentReposCommand(flags) {
8612
+ const accountId = Number(flags['account-id'] || flags.accountId);
8613
+ const directory = flags.directory || flags.dir;
8614
+
8615
+ if (Number.isFinite(accountId) && accountId > 0 && directory) {
8616
+ const resolved = path.resolve(String(directory));
8617
+ if (!fs.existsSync(resolved)) {
8618
+ throw new Error('No such directory: ' + resolved);
8619
+ }
8620
+ if (!isAccountCheckout(resolved)) {
8621
+ throw new Error(resolved + ' is not a git checkout. A ticket worker edits, stages and reports ' +
8622
+ 'against this directory, so an unpacked export would silently lose the work.');
8623
+ }
8624
+ const info = loadAccountInfo(resolved);
8625
+ const declared = info ? Number(accountIdFromAccountInfo(info)) : null;
8626
+ if (declared && declared !== accountId) {
8627
+ throw new Error(resolved + ' declares account ' + declared + ', not ' + accountId +
8628
+ '. Point account ' + declared + ' at it instead, or pass the directory that belongs to ' + accountId + '.');
8629
+ }
8630
+ const index = readAccountRepoIndex();
8631
+ const previous = index[String(accountId)];
8632
+ index[String(accountId)] = {
8633
+ ...(previous || {}),
8634
+ ...(info ? buildAccountRepoEntryFromInfo(info, resolved, 'manual') : {}),
8635
+ accountId,
8636
+ directory: resolved,
8637
+ source: 'manual',
8638
+ // The operator has just answered the question, so the rival is no longer ambiguity.
8639
+ ambiguousWith: [],
8640
+ updatedAt: new Date().toISOString()
8641
+ };
8642
+ writeAccountRepoIndex(index);
8643
+ console.log('account ' + accountId + ' -> ' + resolved);
8644
+ if (previous && previous.directory && previous.directory !== resolved) {
8645
+ console.log(' (was ' + previous.directory + ')');
8646
+ }
8647
+ return;
8648
+ }
8649
+
8650
+ if (Number.isFinite(accountId) && accountId > 0 && flagEnabled(flags.forget)) {
8651
+ const index = readAccountRepoIndex();
8652
+ const previous = index[String(accountId)];
8653
+ delete index[String(accountId)];
8654
+ writeAccountRepoIndex(index);
8655
+ console.log(previous
8656
+ ? 'account ' + accountId + ' removed (was ' + previous.directory + '). Its tickets will now park ' +
8657
+ 'with an explanation rather than being worked in that directory.'
8658
+ : 'account ' + accountId + ' was not in the index.');
8659
+ return;
8660
+ }
8661
+
8662
+ if (flagEnabled(flags.rescan)) {
8663
+ try { discoverAccountRepos(); } catch (err) { console.error('scan failed: ' + describeError(err)); }
8664
+ }
8665
+
8666
+ const entries = listAccountRepoEntries(readAccountRepoIndex())
8667
+ .sort((a, b) => Number(a.accountId) - Number(b.accountId));
8668
+ if (flagEnabled(flags.json)) {
8669
+ console.log(JSON.stringify({ repos: entries, problems: accountRepoIndexProblems() }, null, 2));
8670
+ return;
8671
+ }
8672
+
8673
+ console.log('Account repositories on this machine (' + entries.length + '):');
8674
+ console.log('');
8675
+ for (const entry of entries) {
8676
+ const ok = isAccountCheckout(entry.directory);
8677
+ const missing = !fs.existsSync(entry.directory);
8678
+ const state = missing ? 'MISSING ' : (ok ? 'ok ' : 'NOT-GIT ');
8679
+ console.log(' ' + state + String(entry.accountId).padStart(5) + ' ' +
8680
+ String(entry.name || '').slice(0, 26).padEnd(26) + ' ' + entry.directory);
8681
+ for (const rival of entry.ambiguousWith || []) {
8682
+ console.log(' also claims this account: ' + rival);
8683
+ }
8684
+ }
8685
+
8686
+ const problems = accountRepoIndexProblems();
8687
+ if (problems.length) {
8688
+ console.log('');
8689
+ console.log(problems.length + ' issue(s):');
8690
+ for (const problem of problems) {
8691
+ console.log(' [' + problem.severity + '] ' + problem.message);
8692
+ }
8693
+ }
8694
+ console.log('');
8695
+ console.log(' Set one: remits-cli agent repos --account-id <id> --directory <path>');
8696
+ console.log(' Remove one: remits-cli agent repos --account-id <id> --forget');
8697
+ console.log(' Rescan: remits-cli agent repos --rescan');
8698
+ }
8699
+
7776
8700
  async function agentReleaseCommand(flags) {
7777
8701
  const agentId = resolveAgentId(flags, { optional: true });
7778
8702
  if (!agentId) {
@@ -7813,6 +8737,7 @@ async function agentCommand(flags, subcommand) {
7813
8737
  case 'work': return agentWorkCommand(flags);
7814
8738
  case 'list': case 'ls': return agentListCommand(flags);
7815
8739
  case 'map': case 'work-map': case 'workmap': return agentWorkMapCommand(flags);
8740
+ case 'repos': case 'repo': return agentReposCommand(flags);
7816
8741
  case 'release': case 'deregister': case 'stop': return agentReleaseCommand(flags);
7817
8742
  case 'heartbeat-daemon': return agentHeartbeatDaemonCommand(flags);
7818
8743
  default:
@@ -7858,6 +8783,12 @@ function printAgentHelp() {
7858
8783
  console.log(' map [--account-ids 1,4] [--json] Who is EDITING which repository right now.');
7859
8784
  console.log(' One agent at a time may edit an account\'s repo (the edit lease); everyone else');
7860
8785
  console.log(' investigates read-only. Check this before starting work that changes files.');
8786
+ console.log(' repos [--rescan] [--json] WHERE each account is checked out on this machine —');
8787
+ console.log(' the one fact the platform cannot supply, and what decides the directory a ticket');
8788
+ console.log(' worker is launched into. Flags anything that is not a git checkout (an unpacked');
8789
+ console.log(' export looks identical to the scanner) and any account claimed by two checkouts.');
8790
+ console.log(' Correct it with --account-id <id> --directory <path>, or --account-id <id> --forget');
8791
+ console.log(' to make that account\'s tickets park instead of being worked somewhere wrong.');
7861
8792
  console.log(' release Deregister this session immediately.');
7862
8793
  console.log('');
7863
8794
  console.log(' After `register`, the other subcommands need no flags: they reuse the platform, lane');
@@ -8366,8 +9297,16 @@ function printToolHelp() {
8366
9297
  console.log('Usage: remits-cli tool --name <toolName> [--base-url URL] [--account-id ID] [--branch BRANCH] [--input "{...}"|--input-file file.json] [--data-mode test|prod] [--variant-branch NAME|none] [--timeout-ms 60000] [--async true --wait true]');
8367
9298
  console.log(' remits-cli tool status --call-id <callId> [--base-url URL] [--account-id ID] [--data-mode test|prod]');
8368
9299
  console.log('');
9300
+ console.log('Hierarchy scope (read/discovery tools) — you do NOT need the owning child account id:');
9301
+ console.log(' --scope self|children|hierarchy expand from the repo account when searching');
9302
+ console.log(' --target-account-id ID the exact account to act on (required by writes)');
9303
+ console.log(' --account-ids 1,2,3 an explicit bounded list of owner accounts');
9304
+ console.log(' --anchor-account-id ID disambiguate the path for a multi-parent account');
9305
+ console.log(' These are merged into --input; a value already in --input wins.');
9306
+ console.log('');
8369
9307
  console.log('Examples:');
8370
9308
  console.log(' remits-cli tool --name mcp_firestore_search --input-file query.json --data-mode prod');
9309
+ console.log(' remits-cli tool --name mcp_firestore_search --input \'{"collection":"statements","documentId":"1234"}\' --scope children');
8371
9310
  console.log(' remits-cli tool --name mcp_run_action --input \'{"accountId":49,"actionId":200,"executionMode":"async"}\' --data-mode prod');
8372
9311
  console.log('');
8373
9312
  console.log('For mcp_run_action and mcp_run_agent, prefer the tool\'s own executionMode:"async" and poll with that tool\'s controlAction:"status".');
@@ -8409,6 +9348,15 @@ async function main() {
8409
9348
  if (autoUpdateIfNeeded(originalArgv, { requireSuccess: requireSuccessfulAutoUpdate })) return;
8410
9349
  }
8411
9350
 
9351
+ // Refresh the installed skill on EVERY command, not just from inside an account repo. This runs
9352
+ // after the auto-update re-exec, so it is the new version writing the new skill. It was previously
9353
+ // hung off updateAccountRepoIndex(), which returns early outside a repo — so a user who upgraded and
9354
+ // then ran `remits-cli agent serve` from a plain tab (the documented way to work tickets, explicitly
9355
+ // "no need to be in an account repo") kept the PREVIOUS skill on disk indefinitely: a stale index
9356
+ // pointing at reference files that the upgrade had just renamed or removed. Reading and comparing the
9357
+ // whole corpus across all three agent targets measures ~3ms, so there is nothing to save by guarding it.
9358
+ try { syncInstalledSkills(); } catch (_) { /* best-effort */ }
9359
+
8412
9360
  // Best-effort: update account-repo index whenever running from inside a remits repo
8413
9361
  try { updateAccountRepoIndex(process.cwd()); } catch (_) { /* not in account repo */ }
8414
9362
  // Auto-start the background service if not already running.
@@ -8454,7 +9402,7 @@ async function main() {
8454
9402
  console.log(' remits-cli data-mode [set test|prod]');
8455
9403
  console.log(' remits-cli install --skills [--target codex|claude|gemini|all] [--overwrite true]');
8456
9404
  console.log(' remits-cli tools [--base-url URL] [--account-id ID] [--branch BRANCH] [--data-mode test|prod] [--variant-branch NAME|none]');
8457
- console.log(' remits-cli tool --name <toolName> [--base-url URL] [--branch BRANCH] [--input \"{...}\"|--input-file file.json] [--data-mode test|prod] [--variant-branch NAME|none] [--timeout-ms 60000] [--async true --wait true]');
9405
+ console.log(' remits-cli tool --name <toolName> [--base-url URL] [--branch BRANCH] [--input \"{...}\"|--input-file file.json] [--data-mode test|prod] [--scope self|children|hierarchy] [--target-account-id ID] [--account-ids 1,2,3] [--variant-branch NAME|none] [--timeout-ms 60000] [--async true --wait true]');
8458
9406
  console.log(' remits-cli tool status --call-id <callId> [--base-url URL] [--account-id ID] [--data-mode test|prod]');
8459
9407
  console.log(' remits-cli workspace [show|use <name>|use --auto|clear] # isolate staging when several agents share a repo');
8460
9408
  console.log(' remits-cli components stage [--base-url URL] [--account-id ID] [--branch BRANCH] [--workspace NAME] [--changed-only] [--data-mode test|prod] [--json|--verbose]');