@hone-ai/cli 1.37.0 → 1.38.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/hone-cli.js +43 -14
- package/lib/asset-fetch-list.js +7 -3
- package/lib/operator-question.js +94 -0
- package/lib/run-report.js +29 -0
- package/lib/run-target.js +33 -12
- package/package.json +1 -1
package/hone-cli.js
CHANGED
|
@@ -39,7 +39,8 @@ const { parseReviewJSON } = require('./lib/parse-review-json');
|
|
|
39
39
|
const { resolveBaseRef, getMaxDiffChars } = require('./lib/release-review-config');
|
|
40
40
|
const { gitEnv } = require('./lib/git-env');
|
|
41
41
|
const { collectIncludePaths } = require('./lib/include-paths');
|
|
42
|
-
const { runArgsError, orchestratePath } = require('./lib/run-target'); // HC-GF-270-followup-1
|
|
42
|
+
const { runArgsError, orchestratePath, apiPath } = require('./lib/run-target'); // HC-GF-270-followup-1, -2
|
|
43
|
+
const { normalizeQuestion, questionLines, gateQuestionLines, countPhrase } = require('./lib/operator-question'); // HC-GF-234
|
|
43
44
|
const program = new Command();
|
|
44
45
|
|
|
45
46
|
// HC-GF-331: spawnSync's default stdout/stderr buffer is 1 MiB. A verification suite over that
|
|
@@ -1754,7 +1755,7 @@ program
|
|
|
1754
1755
|
await sleep(pollMs);
|
|
1755
1756
|
process.stdout.write('.');
|
|
1756
1757
|
try {
|
|
1757
|
-
const r = await client.get(
|
|
1758
|
+
const r = await client.get(apiPath('derive', [jobId]));
|
|
1758
1759
|
if (r.data.status === 'complete') {
|
|
1759
1760
|
result = r.data;
|
|
1760
1761
|
break;
|
|
@@ -1991,6 +1992,10 @@ program
|
|
|
1991
1992
|
.requiredOption('--job <jobId>', 'Job ID returned by "hone derive --refresh"')
|
|
1992
1993
|
.option('--changes <mode>', 'Which changes to apply: all (default)', 'all')
|
|
1993
1994
|
.action(async (opts) => {
|
|
1995
|
+
// HC-GF-270-followup-2: this GETs the job named by `--job` and then POSTs its approval, so the id is
|
|
1996
|
+
// checked before any config read or request: `drv_x/../../orchestrate/<run>/approve?` became a POST
|
|
1997
|
+
// to another endpoint's approval.
|
|
1998
|
+
refuseBadRunArgs([{ label: '--job', kind: 'deriveJobId', value: opts.job }]);
|
|
1994
1999
|
const config = getConfig();
|
|
1995
2000
|
const client = api(config);
|
|
1996
2001
|
|
|
@@ -1999,7 +2004,7 @@ program
|
|
|
1999
2004
|
// Verify job is ready
|
|
2000
2005
|
let jobData;
|
|
2001
2006
|
try {
|
|
2002
|
-
const r = await client.get(
|
|
2007
|
+
const r = await client.get(apiPath('derive', [opts.job]));
|
|
2003
2008
|
jobData = r.data;
|
|
2004
2009
|
} catch (e) {
|
|
2005
2010
|
console.error(`Failed to fetch job: ${e.response?.data?.error || e.message}`);
|
|
@@ -2017,7 +2022,7 @@ program
|
|
|
2017
2022
|
|
|
2018
2023
|
// Apply changes
|
|
2019
2024
|
try {
|
|
2020
|
-
const r = await client.post(
|
|
2025
|
+
const r = await client.post(apiPath('derive', [opts.job, 'approve']), {
|
|
2021
2026
|
approvedChanges: opts.changes,
|
|
2022
2027
|
});
|
|
2023
2028
|
console.log('✓ Changes approved and applied');
|
|
@@ -2513,6 +2518,7 @@ program
|
|
|
2513
2518
|
.option('--with-learnings-report', 'Also fetch promoted-learnings report + update local YAML status (H-013)')
|
|
2514
2519
|
.option('--force', 'Overwrite local edits (default: skip files with local changes — H-024)')
|
|
2515
2520
|
.action(async (opts) => {
|
|
2521
|
+
refuseBadRunArgs([{ label: '--repo', kind: 'pathName', value: opts.repo, optional: true }]); // HC-GF-270-followup-2
|
|
2516
2522
|
const config = getConfig();
|
|
2517
2523
|
const client = api(config);
|
|
2518
2524
|
const repoName = opts.repo || path.basename(process.cwd());
|
|
@@ -2548,7 +2554,7 @@ program
|
|
|
2548
2554
|
// ── 1. Sync derived skills ──────────────────────────────────────────────
|
|
2549
2555
|
if (!opts.agentsOnly) {
|
|
2550
2556
|
try {
|
|
2551
|
-
const r = await client.get(
|
|
2557
|
+
const r = await client.get(apiPath('skills', [repoName, 'derived']));
|
|
2552
2558
|
const { skills } = r.data;
|
|
2553
2559
|
if (!skills.length) {
|
|
2554
2560
|
console.log('No derived skills found. Run "hone derive" first.');
|
|
@@ -2628,7 +2634,7 @@ program
|
|
|
2628
2634
|
content = editorStubContent(agent);
|
|
2629
2635
|
console.log(` → ${agent}: editor mode — syncing runtime stub (full prompt served ephemerally)`);
|
|
2630
2636
|
} else {
|
|
2631
|
-
const r = await client.get(
|
|
2637
|
+
const r = await client.get(apiPath('prompts', [agent]), {
|
|
2632
2638
|
responseType: 'text',
|
|
2633
2639
|
headers: { Accept: 'text/markdown' },
|
|
2634
2640
|
});
|
|
@@ -2939,8 +2945,7 @@ program
|
|
|
2939
2945
|
// The default request is byte-identical to the one that shipped — no query string at all —
|
|
2940
2946
|
// so nothing that already calls this path shifts, including HC-GF-210's pin on it. The
|
|
2941
2947
|
// parameter appears only when the operator asked for a status other than the default.
|
|
2942
|
-
const
|
|
2943
|
-
const { data } = await client.get(`/learnings/promoted${qs}`);
|
|
2948
|
+
const { data } = await client.get(apiPath('learnings', ['promoted'], { status: status === 'promoted' ? undefined : status }));
|
|
2944
2949
|
const all = Array.isArray(data.learnings) ? data.learnings : [];
|
|
2945
2950
|
// The two ways a promoted row reaches no run. `targetKind` is 'unrouted' when HC-GF-159a's
|
|
2946
2951
|
// router found no target; a row promoted before that story carries neither field at all.
|
|
@@ -5958,11 +5963,7 @@ program
|
|
|
5958
5963
|
const config = getConfig();
|
|
5959
5964
|
const client = api(config);
|
|
5960
5965
|
try {
|
|
5961
|
-
const
|
|
5962
|
-
if (opts.status) params.set('status', opts.status);
|
|
5963
|
-
if (opts.limit) params.set('limit', opts.limit);
|
|
5964
|
-
const qs = params.toString();
|
|
5965
|
-
const { data } = await client.get(`/orchestrate${qs ? `?${qs}` : ''}`);
|
|
5966
|
+
const { data } = await client.get(apiPath('orchestrate', [], { status: opts.status, limit: opts.limit }));
|
|
5966
5967
|
if (opts.format === 'json') { console.log(JSON.stringify(data, null, 2)); return; }
|
|
5967
5968
|
if (!data.runs.length) { console.log('No workflow runs found.'); return; }
|
|
5968
5969
|
const age = (ts) => {
|
|
@@ -6146,7 +6147,7 @@ function runContextIo(apiConfig) {
|
|
|
6146
6147
|
}
|
|
6147
6148
|
},
|
|
6148
6149
|
fetchStoryContext: async (storyId) => {
|
|
6149
|
-
const { data } = await api(apiConfig).get(
|
|
6150
|
+
const { data } = await api(apiConfig).get(apiPath('stories', [storyId, 'context']));
|
|
6150
6151
|
return data;
|
|
6151
6152
|
},
|
|
6152
6153
|
recentlyModifiedOverlap: (touched) =>
|
|
@@ -6419,6 +6420,16 @@ program
|
|
|
6419
6420
|
console.log(` (auto-verify skipped: ${e.message})`);
|
|
6420
6421
|
}
|
|
6421
6422
|
|
|
6423
|
+
// HC-GF-234: show what the step ASKED where the operator decides. Approving records
|
|
6424
|
+
// `approved` and moves on — it does not answer the question, and until now nothing at
|
|
6425
|
+
// this prompt said there was one. Printed last, right above the prompt (auto-verify's
|
|
6426
|
+
// output above can run to pages), and in both the gate and the exhausted branch: the
|
|
6427
|
+
// step's question rides on the attempt the operator is about to decide about.
|
|
6428
|
+
// Never throws: a print must not end a poll loop.
|
|
6429
|
+
try {
|
|
6430
|
+
for (const line of gateQuestionLines(status, d.stepKey)) console.log(line);
|
|
6431
|
+
} catch { /* a print must never end a poll loop */ }
|
|
6432
|
+
|
|
6422
6433
|
if (process.stdin.isTTY) {
|
|
6423
6434
|
const readline = require('readline');
|
|
6424
6435
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
@@ -6481,6 +6492,14 @@ program
|
|
|
6481
6492
|
// step says so instead of listing a stale failed row above a completed one.
|
|
6482
6493
|
const tries = (s.attempts || 1) > 1 ? ` [attempt ${s.attempt} of ${s.attempts}]` : '';
|
|
6483
6494
|
console.log(` ${icon} ${s.stepKey} (${s.agent}) ${s.status}${gate}${tries}`);
|
|
6495
|
+
// HC-GF-234: `status: completed` / `gateResult: approved` alone cannot tell "did its
|
|
6496
|
+
// work" apart from "asked a question nobody answered" — batch mode has no human gate
|
|
6497
|
+
// on steps 0-3b, so this line is the only place that question ever surfaces here.
|
|
6498
|
+
const asked = normalizeQuestion(s.operatorQuestion);
|
|
6499
|
+
if (asked) {
|
|
6500
|
+
console.log(` asked ${countPhrase(asked.count)} nobody answered:`);
|
|
6501
|
+
for (const l of questionLines(asked, { max: 120 })) console.log(` ${l}`);
|
|
6502
|
+
}
|
|
6484
6503
|
}
|
|
6485
6504
|
if (data.awaitingApproval && isEditorParked(data)) {
|
|
6486
6505
|
console.log('');
|
|
@@ -7693,6 +7712,16 @@ showCmd
|
|
|
7693
7712
|
console.log(` startedAt: ${s.startedAt || '(not started)'}`);
|
|
7694
7713
|
console.log(` completedAt: ${s.completedAt || '(not completed)'}`);
|
|
7695
7714
|
if (s.tokenUsage) console.log(` tokenUsage: ${JSON.stringify(s.tokenUsage)}`);
|
|
7715
|
+
// HC-GF-234: `status`/`gateResult` above read identically whether this step did its
|
|
7716
|
+
// work or ended by asking the operator something nobody answered — surface it here so
|
|
7717
|
+
// an operator does not have to read raw step output to find out.
|
|
7718
|
+
const asked = normalizeQuestion(s.operatorQuestion);
|
|
7719
|
+
if (asked) {
|
|
7720
|
+
console.log(``);
|
|
7721
|
+
console.log(`── operator question(s) — asked, not answered ──────────`);
|
|
7722
|
+
console.log(` asked ${asked.count} question(s):`);
|
|
7723
|
+
for (const l of questionLines(asked)) console.log(` ${l}`);
|
|
7724
|
+
}
|
|
7696
7725
|
if (s.errorContext) {
|
|
7697
7726
|
console.log(``);
|
|
7698
7727
|
console.log(`── error_context (retry feedback) ──────────────────────`);
|
package/lib/asset-fetch-list.js
CHANGED
|
@@ -11,6 +11,10 @@
|
|
|
11
11
|
* network or a filesystem.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
+
// HC-GF-270-followup-2: every path goes through the one builder. The derived set's repository is the
|
|
15
|
+
// checkout's directory name, which can hold `?`, `#` or `%`; hand-built, `x?y` fetched `/skills/x`.
|
|
16
|
+
const { apiPath } = require('./run-target');
|
|
17
|
+
|
|
14
18
|
/**
|
|
15
19
|
* @param {object} args
|
|
16
20
|
* @param {string[]} args.agents agent names
|
|
@@ -39,12 +43,12 @@ function assetFetchList({ agents = [], skills = [], derivedRepo = null, get, wri
|
|
|
39
43
|
return [
|
|
40
44
|
...agents.map(agent => ({
|
|
41
45
|
key: `${agent}.agent.md`,
|
|
42
|
-
promise: get(
|
|
46
|
+
promise: get(apiPath('scripts', ['agent-stub', agent])),
|
|
43
47
|
write: (data) => writeAgent(agent, data),
|
|
44
48
|
})),
|
|
45
49
|
...skills.map(skill => ({
|
|
46
50
|
key: `skills/${skill}/SKILL.md`,
|
|
47
|
-
promise: get(
|
|
51
|
+
promise: get(apiPath('skills', [skill])),
|
|
48
52
|
write: (data) => writeSkill(skill, data),
|
|
49
53
|
})),
|
|
50
54
|
// ── HC-GF-184: the per-repo DERIVED set ────────────────────────────────────────────────────
|
|
@@ -59,7 +63,7 @@ function assetFetchList({ agents = [], skills = [], derivedRepo = null, get, wri
|
|
|
59
63
|
// derived, so setup behaves exactly as before for them.
|
|
60
64
|
...(derivedRepo ? [{
|
|
61
65
|
key: `skills/<derived for ${derivedRepo}>`,
|
|
62
|
-
promise: get(
|
|
66
|
+
promise: get(apiPath('skills', [derivedRepo, 'derived'])),
|
|
63
67
|
// Tolerant by construction: this runs mid-setup, AFTER the baseline has landed, so a shape
|
|
64
68
|
// change at the endpoint must not abort an adopter's install half-written.
|
|
65
69
|
write: (data) => {
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* operator-question.js — HC-GF-234: render the operator question(s) a step asked and nobody answered.
|
|
3
|
+
*
|
|
4
|
+
* A step that ends by asking the operator something used to be recorded exactly like a step that
|
|
5
|
+
* did its work (`status: completed`, `gateResult: approved`). The server now stores
|
|
6
|
+
* `{ count, questions }` on the step's attempt row and carries it as `operatorQuestion` on
|
|
7
|
+
* `GET /orchestrate/:runId` and `.../step/:stepKey`. Four CLI surfaces print it — `--status`,
|
|
8
|
+
* `show step`, the human-gate prompt, and the NEEDS YOU list — and they share this module so the
|
|
9
|
+
* wording and the two honesty rules below cannot drift apart.
|
|
10
|
+
*
|
|
11
|
+
* PURE: data in, strings out. No fs, no console, no process — a leaf, so run-report.js can keep
|
|
12
|
+
* its "derived, never narrated" contract and require it.
|
|
13
|
+
*
|
|
14
|
+
* TWO HONESTY RULES.
|
|
15
|
+
* - `count` is the TRUE number of questions the step asked; `questions` holds at most the first
|
|
16
|
+
* five. When count exceeds what is stored, the surfaces say "+N more not stored" instead of
|
|
17
|
+
* letting a short list read as the whole of it.
|
|
18
|
+
* - The text is AGENT-WRITTEN and goes to a terminal. The server scrubs control characters
|
|
19
|
+
* before storing, but this CLI may talk to an older or different server, so it scrubs again
|
|
20
|
+
* at the point of printing: C0/C1 controls, DEL, and the bidirectional controls that reorder
|
|
21
|
+
* what a terminal draws.
|
|
22
|
+
*
|
|
23
|
+
* TOLERANT OF AN OLDER SERVER BY CONSTRUCTION: a missing key, null, or a payload of the wrong
|
|
24
|
+
* shape normalises to null, which is today's output (nothing printed).
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
// U+061C ARABIC LETTER MARK is the third bidi mark beside LRM/RLM (review round 3 F3). Written as
|
|
28
|
+
// \u escapes: a literal bidi character in this source would reorder how THIS file reads.
|
|
29
|
+
// eslint-disable-next-line no-control-regex -- the whole point is to match control chars.
|
|
30
|
+
const TERMINAL_UNSAFE_RE = /[\x00-\x1f\x7f-\x9f\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/g;
|
|
31
|
+
|
|
32
|
+
function scrub(text) {
|
|
33
|
+
return String(text == null ? '' : text).replace(TERMINAL_UNSAFE_RE, '');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* @param {*} oq a step's `operatorQuestion`, straight off the network (not trusted to have a shape)
|
|
38
|
+
* @returns {{count:number, questions:string[], more:number}|null} null = the step did not ask
|
|
39
|
+
*/
|
|
40
|
+
function normalizeQuestion(oq) {
|
|
41
|
+
if (!oq || typeof oq !== 'object') return null;
|
|
42
|
+
const count = Math.floor(Number(oq.count));
|
|
43
|
+
if (!Number.isFinite(count) || count < 1) return null;
|
|
44
|
+
const questions = (Array.isArray(oq.questions) ? oq.questions : []).map(scrub);
|
|
45
|
+
return { count, questions, more: Math.max(0, count - questions.length) };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Clips by CODE POINT, not UTF-16 unit (review round 3 F4): a `.slice(0, max)` that lands between
|
|
49
|
+
// the halves of an emoji prints a lone surrogate, which a terminal draws as U+FFFD.
|
|
50
|
+
function clip(text, max) {
|
|
51
|
+
if (!Number.isFinite(max)) return text;
|
|
52
|
+
const points = Array.from(text);
|
|
53
|
+
return points.length > max ? `${points.slice(0, max).join('')}...` : text;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* One entry per stored question (`- <question>`, clipped to `max` chars) and, when the step asked
|
|
58
|
+
* more than was stored, a closing `+ N more not stored`. Callers add their own indentation.
|
|
59
|
+
*/
|
|
60
|
+
function questionLines(asked, { max = Infinity } = {}) {
|
|
61
|
+
if (!asked) return [];
|
|
62
|
+
const lines = asked.questions.map((q) => `- ${clip(q, max)}`);
|
|
63
|
+
if (asked.more > 0) lines.push(`+ ${asked.more} more not stored`);
|
|
64
|
+
return lines;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function countPhrase(count) {
|
|
68
|
+
return `${count} question${count === 1 ? '' : 's'}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The block printed at the human gate, right before "Approve?" — the one place the operator
|
|
73
|
+
* decides, and where approving silently consumes the question without answering it.
|
|
74
|
+
*
|
|
75
|
+
* SCOPED TO THE STEP THE RUN IS PAUSED AT (`stepKey`), the same discipline as
|
|
76
|
+
* hard-pause-remedy.js's remedyFor: the column is durable, so a question step_3b asked must not
|
|
77
|
+
* be re-printed as if it belonged to an unrelated step_4 gate.
|
|
78
|
+
*
|
|
79
|
+
* @param {object} status a GET /orchestrate/:runId payload
|
|
80
|
+
* @param {string} stepKey the step the gate is for
|
|
81
|
+
* @returns {string[]} lines to print (empty when that step asked nothing)
|
|
82
|
+
*/
|
|
83
|
+
function gateQuestionLines(status, stepKey) {
|
|
84
|
+
const steps = status && Array.isArray(status.steps) ? status.steps : [];
|
|
85
|
+
const step = steps.find((x) => x && typeof x === 'object' && x.stepKey === stepKey);
|
|
86
|
+
const asked = step ? normalizeQuestion(step.operatorQuestion) : null;
|
|
87
|
+
if (!asked) return [];
|
|
88
|
+
return [
|
|
89
|
+
` ${stepKey} asked ${countPhrase(asked.count)} that nobody has answered — approving does not answer ${asked.count === 1 ? 'it' : 'them'}:`,
|
|
90
|
+
...questionLines(asked, { max: 240 }).map((l) => ` ${l}`),
|
|
91
|
+
];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
module.exports = { normalizeQuestion, questionLines, gateQuestionLines, countPhrase };
|
package/lib/run-report.js
CHANGED
|
@@ -16,6 +16,10 @@
|
|
|
16
16
|
* Rendering is separate so both are testable without a server.
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
+
// HC-GF-234: the one wording for a step's unanswered question, shared with `--status`, `show
|
|
20
|
+
// step` and the human-gate prompt. A pure leaf module, so this file stays pure.
|
|
21
|
+
const { normalizeQuestion, questionLines, countPhrase } = require('./operator-question');
|
|
22
|
+
|
|
19
23
|
/** Gate verdicts the pipeline already speaks. Do not invent synonyms. */
|
|
20
24
|
const ACTIONABLE_GATE_VERDICTS = ['fail', 'unavailable'];
|
|
21
25
|
|
|
@@ -324,6 +328,31 @@ function buildReport(run, extra = {}) {
|
|
|
324
328
|
}
|
|
325
329
|
}
|
|
326
330
|
|
|
331
|
+
// 1e. HC-GF-234: a step that ended by asking the operator something.
|
|
332
|
+
//
|
|
333
|
+
// `status: completed` / `gateResult: approved` cannot say this: batch mode has no human gate on
|
|
334
|
+
// steps 0-3b, and approving a gate records `approved` whether or not the question was read. The
|
|
335
|
+
// step row carries the question on its own column; this is the only place a TERMINAL run's
|
|
336
|
+
// report can tell "did its work" from "asked, and nobody here answered".
|
|
337
|
+
//
|
|
338
|
+
// Says only what is MEASURED — the step asked, and the run has no answer to it. It does not say
|
|
339
|
+
// the step produced no deliverable or that the run proceeded on a guess: nothing checks either.
|
|
340
|
+
//
|
|
341
|
+
// ADVISORY (HC-026c: a new signal REPORTS before it GATES): `hone run-report` exits 1 on any
|
|
342
|
+
// non-advisory item, and an agent that asks one benign question must not turn an adopter's
|
|
343
|
+
// passing script red overnight. Visible in the report, absent from the exit code.
|
|
344
|
+
for (const s of steps) {
|
|
345
|
+
const asked = normalizeQuestion(s.operatorQuestion);
|
|
346
|
+
if (!asked) continue;
|
|
347
|
+
needsYou.push({
|
|
348
|
+
what: `${s.stepKey} (${s.agent || 'agent'}) asked ${countPhrase(asked.count)} that nothing in this run answered`,
|
|
349
|
+
detail: questionLines(asked, { max: 200 }),
|
|
350
|
+
how: `hone show step ${run.runId} ${s.stepKey}`,
|
|
351
|
+
howLabel: 'inspect',
|
|
352
|
+
advisory: true,
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
|
|
327
356
|
// 2. The last verification is ONE event, so it becomes ONE item with sub-lines.
|
|
328
357
|
// Listing "verification failed" and "lint failed" separately gave two entries
|
|
329
358
|
// for one problem resolved by one fix — and NEEDS YOU is a list of DISTINCT
|
package/lib/run-target.js
CHANGED
|
@@ -104,17 +104,26 @@ async function resolveRunTarget({ client, idOrStory, repoName, flag }) {
|
|
|
104
104
|
const isStepKey = (s) => typeof s === 'string' && /^step_[0-9a-z_]+$/i.test(s);
|
|
105
105
|
const isAttempt = (s) => (typeof s === 'string' || typeof s === 'number') && /^[1-9]\d{0,8}$/.test(String(s));
|
|
106
106
|
const isBatchId = isRunId;
|
|
107
|
+
// HC-GF-270-followup-2: POST /derive is the only producer of a derive job id (server/src/routes/derive.js),
|
|
108
|
+
// and it issues `drv_` + 12 lower-case hex characters into a TEXT key compared exactly, so no other shape
|
|
109
|
+
// can name a job. `hone approve --job` GETs and then POSTs under that id.
|
|
110
|
+
const isDeriveJobId = (s) => typeof s === 'string' && /^drv_[0-9a-f]{12}$/.test(s);
|
|
111
|
+
// A name that becomes ONE path segment, such as a repository name. Encoding makes any other character
|
|
112
|
+
// safe; these three it cannot, because `new URL()` resolves `.` and `..` as directory steps.
|
|
113
|
+
const isPathName = (s) => typeof s === 'string' && s !== '' && s !== '.' && s !== '..';
|
|
107
114
|
|
|
108
115
|
const KINDS = {
|
|
109
116
|
runId: { ok: isRunId, why: 'must be a run ID (a UUID)', hint: ' — run IDs: hone workflows' },
|
|
110
117
|
batchId: { ok: isBatchId, why: 'must be a batch ID (a UUID)', hint: ' — batch IDs: hone queue-stories --list' },
|
|
111
118
|
stepKey: { ok: isStepKey, why: 'must name a step, like step_4', hint: '' },
|
|
112
119
|
attempt: { ok: isAttempt, why: 'must be a whole number from 1, like 2', hint: '' },
|
|
120
|
+
deriveJobId: { ok: isDeriveJobId, why: 'must be a derive job ID, like drv_0123456789ab', hint: ' — the ID "hone derive --refresh" printed' },
|
|
121
|
+
pathName: { ok: isPathName, why: 'must be a name, not empty, "." or ".."', hint: '' },
|
|
113
122
|
};
|
|
114
123
|
|
|
115
124
|
/**
|
|
116
125
|
* The first failed check's message, or null when every check passes.
|
|
117
|
-
* @param {Array<{ label: string, kind: 'runId'|'batchId'|'stepKey'|'attempt', value: any, optional?: boolean }>} checks
|
|
126
|
+
* @param {Array<{ label: string, kind: 'runId'|'batchId'|'stepKey'|'attempt'|'deriveJobId'|'pathName', value: any, optional?: boolean }>} checks
|
|
118
127
|
* `label` is the flag or positional as the operator typed it. An `optional` check passes when the value
|
|
119
128
|
* is undefined (a flag that was not given); an empty string is always checked.
|
|
120
129
|
*/
|
|
@@ -130,23 +139,29 @@ function runArgsError(checks) {
|
|
|
130
139
|
}
|
|
131
140
|
|
|
132
141
|
/**
|
|
133
|
-
* The one way
|
|
134
|
-
*
|
|
135
|
-
*
|
|
136
|
-
*
|
|
142
|
+
* The one way the CLI builds a path on the Hone API: `/<prefix>/<segment>/…?<query>`. `prefix` is a
|
|
143
|
+
* literal resource name (`derive`, `skills`, …), never a value. Each segment is encoded exactly once, and
|
|
144
|
+
* the segments encoding cannot make safe (empty, `.`, `..`, null, undefined) throw: every caller-supplied
|
|
145
|
+
* value has passed `runArgsError` first, so a throw here is a bug, not an input error. Query values that
|
|
146
|
+
* are null, undefined, false or '' are dropped; the rest go through URLSearchParams.
|
|
137
147
|
*
|
|
138
148
|
* Callers pass raw values. A pre-encoded segment would be encoded twice (`%` becomes `%25`), and the
|
|
139
|
-
* HC-GF-270-followup-
|
|
149
|
+
* HC-GF-270-followup-2 scan fails on one.
|
|
150
|
+
*
|
|
151
|
+
* HC-GF-270-followup-2: generalized from orchestratePath. HC-GF-270-followup-1 closed the defect under
|
|
152
|
+
* `/orchestrate/`; `/derive/`, `/skills/`, `/prompts/`, `/scripts/` and `/stories/` were still built by
|
|
153
|
+
* hand, and `hone approve --job` POSTed under an id nothing checked.
|
|
140
154
|
*/
|
|
141
|
-
function
|
|
142
|
-
if (
|
|
155
|
+
function apiPath(prefix, segments, query) {
|
|
156
|
+
if (typeof prefix !== 'string' || !/^[a-z][a-z0-9-]*$/.test(prefix)) throw new TypeError(`apiPath: prefix must be a resource name, got ${JSON.stringify(prefix)}`);
|
|
157
|
+
if (!Array.isArray(segments)) throw new TypeError('apiPath: segments must be an array');
|
|
143
158
|
const parts = segments.map((v, i) => {
|
|
144
|
-
if (v === null || v === undefined) throw new TypeError(`
|
|
159
|
+
if (v === null || v === undefined) throw new TypeError(`apiPath: segment ${i} is ${v}`);
|
|
145
160
|
const s = String(v);
|
|
146
|
-
if (s === '' || s === '.' || s === '..') throw new TypeError(`
|
|
161
|
+
if (s === '' || s === '.' || s === '..') throw new TypeError(`apiPath: segment ${i} is ${JSON.stringify(s)}`);
|
|
147
162
|
return encodeURIComponent(s);
|
|
148
163
|
});
|
|
149
|
-
const base = ['',
|
|
164
|
+
const base = ['', prefix, ...parts].join('/');
|
|
150
165
|
const qs = new URLSearchParams();
|
|
151
166
|
for (const [k, v] of Object.entries(query || {})) {
|
|
152
167
|
if (v === null || v === undefined || v === false || v === '') continue;
|
|
@@ -156,7 +171,13 @@ function orchestratePath(segments, query) {
|
|
|
156
171
|
return q ? `${base}?${q}` : base;
|
|
157
172
|
}
|
|
158
173
|
|
|
174
|
+
/** An `/orchestrate/…` path: `apiPath('orchestrate', …)` with at least one segment (HC-GF-270-followup-1). */
|
|
175
|
+
function orchestratePath(segments, query) {
|
|
176
|
+
if (!Array.isArray(segments) || segments.length === 0) throw new TypeError('orchestratePath: segments must be a non-empty array');
|
|
177
|
+
return apiPath('orchestrate', segments, query);
|
|
178
|
+
}
|
|
179
|
+
|
|
159
180
|
module.exports = {
|
|
160
181
|
resolveRunTarget, isRunId, LOOKUP_LIMIT,
|
|
161
|
-
isStepKey, isAttempt, isBatchId, runArgsError, orchestratePath,
|
|
182
|
+
isStepKey, isAttempt, isBatchId, isDeriveJobId, isPathName, runArgsError, apiPath, orchestratePath,
|
|
162
183
|
};
|