@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/README.md +3 -1
- package/index.js +990 -42
- package/package.json +3 -2
- package/skills/remits-cli/SKILL.md +139 -3366
- package/skills/remits-cli/references/account-targeting.md +165 -0
- package/skills/remits-cli/references/agent-sessions.md +218 -0
- package/skills/remits-cli/references/branch-variants.md +372 -0
- package/skills/remits-cli/references/cli-state.md +158 -0
- package/skills/remits-cli/references/command-reference.md +268 -0
- package/skills/remits-cli/references/component-integrity.md +175 -0
- package/skills/remits-cli/references/component-resolution.md +209 -0
- package/skills/remits-cli/references/development-loop.md +366 -0
- package/skills/remits-cli/references/investigation.md +251 -0
- package/skills/remits-cli/references/support-tickets.md +389 -0
- package/skills/remits-cli/references/tool-reference.md +962 -0
- package/skills/remits-cli/references/troubleshooting.md +135 -0
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
|
|
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
|
-
|
|
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:
|
|
1900
|
-
|
|
1901
|
-
|
|
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
|
|
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
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
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,
|
|
2163
|
+
for (const [agent, spec] of targets) {
|
|
1932
2164
|
try {
|
|
1933
|
-
ensureDir(path.dirname(
|
|
1934
|
-
if (writeFileIfChanged(
|
|
1935
|
-
summary.updated.push({ agent, path:
|
|
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:
|
|
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,
|
|
1962
|
-
ensureDir(path.dirname(
|
|
1963
|
-
if (fs.existsSync(
|
|
1964
|
-
skipped.push({ agent, path:
|
|
1965
|
-
|
|
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',
|
|
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',
|
|
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
|
-
/**
|
|
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 || !
|
|
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
|
|
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('
|
|
7588
|
-
console.log(' Answer the open question. By default this re-routes the ticket,
|
|
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]');
|