@floh-solutions/pharos-cli 0.26.1 → 0.27.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.
@@ -0,0 +1,200 @@
1
+ import { AdoSearchQueryError } from "@floh-solutions/ado-core";
2
+ import { emit, emitText, usageError } from "../output.js";
3
+ const DEFAULT_TOP = 25;
4
+ export async function runSearch(io, session, rest, options) {
5
+ if (options.itemsOnly && options.wikiOnly) {
6
+ throw usageError("Pass either --items or --wiki, not both. Passing neither searches both, which is the "
7
+ + "default and almost always what you want.");
8
+ }
9
+ // Every remaining positional is a term, so `pharos search wiki import` works
10
+ // without quoting. Quoting still behaves identically.
11
+ const terms = rest.join(" ").trim();
12
+ if (terms === "") {
13
+ throw usageError('A search needs terms: pharos search "retry backoff". Azure DevOps answers an empty '
14
+ + "query with 400, so this is refused here rather than paid for as a round trip.");
15
+ }
16
+ const filters = {};
17
+ // PascalCase on the way in, lowercase on the way back — the endpoint's own
18
+ // asymmetry, absorbed in ado-core. See WorkItemSearchOptions.filters.
19
+ if (options.types.length > 0)
20
+ filters["System.WorkItemType"] = options.types;
21
+ if (options.states.length > 0)
22
+ filters["System.State"] = options.states;
23
+ if (options.wikiOnly && (options.types.length > 0 || options.states.length > 0)) {
24
+ throw usageError("--type and --state filter work items, and --wiki excludes work items — so this search "
25
+ + "would silently ignore them. Drop --wiki, or drop the filters.");
26
+ }
27
+ const top = options.top ?? DEFAULT_TOP;
28
+ const shared = {
29
+ top,
30
+ ...(options.skip === undefined ? {} : { skip: options.skip }),
31
+ ...(options.org ? { orgWide: true } : {}),
32
+ };
33
+ const problems = [];
34
+ const search = session.client.search;
35
+ const [items, wiki] = await Promise.all([
36
+ options.wikiOnly
37
+ ? undefined
38
+ : search
39
+ .workItems(terms, { ...shared, ...(Object.keys(filters).length === 0 ? {} : { filters }) })
40
+ .catch((error) => {
41
+ problems.push(`work item search failed: ${describe(error)}`);
42
+ return undefined;
43
+ }),
44
+ options.itemsOnly
45
+ ? undefined
46
+ : search.wiki(terms, shared).catch((error) => {
47
+ problems.push(`wiki search failed: ${describe(error)}`);
48
+ return undefined;
49
+ }),
50
+ ]);
51
+ // Both halves refused: that is a failed search, not an empty one. Exit
52
+ // non-zero so a caller checking only the status does not read it as "nothing
53
+ // is written about this anywhere".
54
+ if (items === undefined && wiki === undefined && problems.length > 0) {
55
+ throw usageError(problems.join(" | "));
56
+ }
57
+ const result = {
58
+ terms,
59
+ scope: options.org ? "organization" : "project",
60
+ ...(items === undefined
61
+ ? {}
62
+ : {
63
+ workItems: {
64
+ count: items.count,
65
+ returned: items.results.length,
66
+ ...(items.emptyBecause === undefined ? {} : { emptyBecause: items.emptyBecause }),
67
+ results: items.results.map(workItemSummary),
68
+ },
69
+ }),
70
+ ...(wiki === undefined
71
+ ? {}
72
+ : {
73
+ wiki: {
74
+ count: wiki.count,
75
+ returned: wiki.results.length,
76
+ ...(wiki.emptyBecause === undefined ? {} : { emptyBecause: wiki.emptyBecause }),
77
+ results: wiki.results.map(wikiSummary),
78
+ },
79
+ }),
80
+ // Stated whenever a total exceeds what was returned, because "6 results"
81
+ // and "6 of 97" are different answers and only one of them is honest.
82
+ ...(truncationNote(items?.count, items?.results.length, wiki?.count, wiki?.results.length, top) ?? {}),
83
+ problems,
84
+ };
85
+ return session.pretty ? emitText(io, render(result)) : emit(io, result);
86
+ }
87
+ function workItemSummary(result) {
88
+ return {
89
+ id: result.id,
90
+ type: result.type,
91
+ title: result.title,
92
+ state: result.state,
93
+ assignedTo: result.assignedTo,
94
+ tags: result.tags,
95
+ changed: result.changed,
96
+ matched: result.hits.map((hit) => ({ field: hit.field, text: hit.highlights })),
97
+ };
98
+ }
99
+ function wikiSummary(result) {
100
+ return {
101
+ path: result.path,
102
+ wiki: result.wiki,
103
+ matched: result.hits.map((hit) => ({ field: hit.field, text: hit.highlights })),
104
+ };
105
+ }
106
+ /**
107
+ * Say so when the page is smaller than the total.
108
+ *
109
+ * Measured: a broad term reports `count: 97` while returning the `$top` asked
110
+ * for. A caller that reports the page size as the answer tells somebody there
111
+ * are three of something when there are ninety-seven.
112
+ */
113
+ function truncationNote(itemCount, itemReturned, wikiCount, wikiReturned, top) {
114
+ const truncated = (itemCount !== undefined && itemReturned !== undefined && itemCount > itemReturned)
115
+ || (wikiCount !== undefined && wikiReturned !== undefined && wikiCount > wikiReturned);
116
+ if (!truncated)
117
+ return undefined;
118
+ return {
119
+ more: `Showing the first ${top} of each. "count" is the true total — raise --top or page with `
120
+ + "--skip to see the rest.",
121
+ };
122
+ }
123
+ function describe(error) {
124
+ if (error instanceof AdoSearchQueryError)
125
+ return error.message;
126
+ return error instanceof Error ? error.message : String(error);
127
+ }
128
+ /**
129
+ * The human rendering.
130
+ *
131
+ * The matching line is the point, so it is indented under its result rather
132
+ * than summarised away — a list of page paths answers "how many" when the
133
+ * question was "where".
134
+ */
135
+ function render(result) {
136
+ const lines = [`"${result.terms}" across the ${result.scope}`, ""];
137
+ if (result.workItems !== undefined) {
138
+ const { count, returned, results } = result.workItems;
139
+ lines.push(`WORK ITEMS — ${count}${count > returned ? ` (showing ${returned})` : ""}`);
140
+ if (results.length === 0) {
141
+ lines.push(` ${result.workItems.emptyBecause ?? "(nothing matched)"}`);
142
+ }
143
+ for (const item of results) {
144
+ lines.push(` #${String(item.id ?? "?").padEnd(6)} ${(item.type ?? "").padEnd(10)} `
145
+ + `${(item.state ?? "").padEnd(12)} ${item.title ?? "(untitled)"}`);
146
+ for (const line of matchLines(item.matched, (hit, text) => `${hit}: ${text}`)) {
147
+ lines.push(` ${line}`);
148
+ }
149
+ }
150
+ lines.push("");
151
+ }
152
+ if (result.wiki !== undefined) {
153
+ const { count, returned, results } = result.wiki;
154
+ lines.push(`WIKI — ${count}${count > returned ? ` (showing ${returned})` : ""}`);
155
+ if (results.length === 0) {
156
+ lines.push(` ${result.wiki.emptyBecause ?? "(nothing matched)"}`);
157
+ }
158
+ for (const page of results) {
159
+ lines.push(` ${page.path ?? "(unknown path)"}`);
160
+ for (const line of matchLines(page.matched, (_hit, text) => text)) {
161
+ lines.push(` ${line}`);
162
+ }
163
+ }
164
+ lines.push("");
165
+ }
166
+ if (result.more !== undefined)
167
+ lines.push(result.more);
168
+ if (result.problems.length > 0) {
169
+ lines.push("", "problems:");
170
+ for (const problem of result.problems)
171
+ lines.push(` - ${problem}`);
172
+ }
173
+ return lines.join("\n").trimEnd();
174
+ }
175
+ /**
176
+ * At most {@link SHOWN_PER_RESULT} matching lines per result, and it SAYS when
177
+ * it dropped some.
178
+ *
179
+ * A single work item can match on a dozen passages across its description and
180
+ * its discussion, and one result then fills a screen. The JSON keeps every one
181
+ * of them — this cap is the human rendering only. It is stated rather than
182
+ * silent: a reader who cannot tell a complete list from a truncated one has to
183
+ * re-run the search to find out.
184
+ */
185
+ const SHOWN_PER_RESULT = 4;
186
+ function matchLines(matched, format) {
187
+ const all = matched.flatMap((hit) => hit.text.map((text) => format(hit.field, oneLine(text))));
188
+ if (all.length <= SHOWN_PER_RESULT)
189
+ return all;
190
+ return [
191
+ ...all.slice(0, SHOWN_PER_RESULT),
192
+ `… and ${all.length - SHOWN_PER_RESULT} more matching lines (the JSON output has all of them)`,
193
+ ];
194
+ }
195
+ /** Highlights carry real newlines; a match is one line in a list. */
196
+ function oneLine(text) {
197
+ const flat = text.replace(/\s+/g, " ").trim();
198
+ return flat.length > 160 ? `${flat.slice(0, 157)}…` : flat;
199
+ }
200
+ //# sourceMappingURL=search.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"search.js","sourceRoot":"","sources":["../../src/commands/search.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@floh-solutions/pharos-cli",
3
- "version": "0.26.1",
3
+ "version": "0.27.0",
4
4
  "description": "Azure DevOps from a headless shell, for an agent: the whole context of a task in one call, and the wiki/comment verbs Microsoft's MCP server does not ship.",
5
5
  "license": "UNLICENSED",
6
6
  "author": "FLOH Solutions",
@@ -24,8 +24,8 @@
24
24
  "node": ">=22"
25
25
  },
26
26
  "dependencies": {
27
- "@floh-solutions/ado-core": "0.10.0",
28
- "@floh-solutions/gh-core": "0.2.1",
27
+ "@floh-solutions/ado-core": "0.11.0",
28
+ "@floh-solutions/gh-core": "0.3.0",
29
29
  "@floh-solutions/plan-to-board": "0.1.1"
30
30
  },
31
31
  "devDependencies": {
package/skill/SKILL.md CHANGED
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: pharos
3
- description: "Use for AZURE DEVOPS work — when the user says Azure DevOps, ADO or dev.azure.com, or names an ADO work item by number (\"pick up 4821\", \"what's on 210\"). Covers reading or updating an ADO work item, ticket, bug, story or epic; the ADO board, backlog, sprint or iteration; what is assigned to you; reading or writing an ADO wiki page and its comments; linking a plan to an epic; attaching a file to a work item or taking one off; inline images in a field or wiki page; @mentioning somebody so they are notified; importing Markdown, Word or PDF as wiki pages; and whether a failed call is retryable. ALSO the GITHUB ISSUE ↔ work item EDGE: adopting an issue as a work item linked at both ends, bulk-adopting a repo, one reply reaching reporter and board, where they have drifted, closing both ends together, and changing a comment that ALREADY EXISTS: editing, deleting, reacting, hiding, pinning. NOT a GitHub CLI: listing or viewing an issue, or POSTING a comment, is `gh`'s job. NOT for other trackers — Argus, Jira, Linear."
3
+ description: "Use for AZURE DEVOPS work — when the user says Azure DevOps, ADO or dev.azure.com, or names an ADO work item by number (\"pick up 4821\"). Covers reading or updating a work item, bug, story or epic; the board, backlog, sprint or iteration; what is assigned to you; reading or writing a wiki page and its comments; searching the board and wiki for a phrase; linking a plan to an epic; attaching a file to a work item or taking one off; inline images in a field or page; @mentioning somebody so they are notified; importing Markdown, Word or PDF as pages; and whether a call is retryable. ALSO the GITHUB ISSUE ↔ work item EDGE: adopting an issue as a work item linked at both ends, bulk-adopting a repo, one reply reaching both, where they drifted, closing both ends together, a PULL REQUEST and its work item, and changing a comment that ALREADY EXISTS: editing, deleting, reacting, hiding, pinning. NOT a GitHub CLI: listing or viewing an issue, or POSTING a comment, is `gh`'s job. NOT for other trackers — Argus, Jira, Linear."
4
4
  license: Proprietary
5
5
  ---
6
6
 
@@ -17,6 +17,12 @@ whoami who ADO_PAT belongs to. @Me resolves to this
17
17
  types what --state and --type will ACCEPT, per type
18
18
  query WHICH work items — assigned to you, in a sprint,
19
19
  of a type, still open. Hydrated items, not ids.
20
+ search <terms...> WHERE something is WRITTEN — full text across
21
+ work items AND wiki pages, in one call. Reaches
22
+ descriptions, discussion and page bodies, which
23
+ `query` cannot. Each result carries the MATCHING
24
+ LINE. --items / --wiki to narrow, --org for the
25
+ whole organisation, --top (default 25)
20
26
  task <id> one work item, whole: fields, comments,
21
27
  attachments, relations WITH titles, and the
22
28
  content + discussion of every linked wiki page
@@ -78,6 +84,13 @@ issue say <owner/name#45> ONE message, TWO audiences: the whole of it to
78
84
  comment's URL to the board. --summary
79
85
  issue trail <id|owner/name#45> the whole trail from EITHER end, with the
80
86
  evidence for each half. Read-only
87
+ pr <owner/name#123> a PULL REQUEST and the work it belongs to:
88
+ state, draft, review verdict, WHICH CHECKS ARE
89
+ RED, the issues it closes, and the work items it
90
+ reaches — via AB#123 in the body OR via an
91
+ adopted issue named by "Fixes #45". Also where
92
+ the pull request and the board DISAGREE
93
+ --no-work-items skip the transitive lookup
81
94
  issue drift where the two platforms DISAGREE — the report no
82
95
  other tool can produce. --repo --limit --wiql
83
96
  issue backfill <owner/name> bulk adopt every issue the board does not link
@@ -188,6 +201,135 @@ It parses, returns 200, and matches **everything** — `IN GROUP` covers work it
188
201
  TYPE categories only, and an unknown group resolves to the empty set with no
189
202
  error. Measured, 2026-08-05.
190
203
 
204
+ ## After adoption, the work becomes code: `pharos pr`
205
+
206
+ `issue trail` follows filing -> adoption -> close. It stops at the moment the
207
+ work becomes a branch. `pharos pr` is the part that does not.
208
+
209
+ ```bash
210
+ pharos pr contoso/widgets#123 # the pull request AND the board
211
+ pharos pr contoso/widgets#123 --pretty
212
+ pharos pr contoso/widgets#123 --no-work-items # skip the transitive lookup
213
+ ```
214
+
215
+ One call replaces `gh pr view` + `gh pr checks` + a WIQL query + a relations
216
+ read, across two credentials — and answers one question none of them can.
217
+
218
+ **Two paths reach a work item, and the implicit one is the one that fires:**
219
+
220
+ | | |
221
+ |---|---|
222
+ | `AB#4821` in the body | explicit. What Azure Boards' own app reads |
223
+ | `Fixes #45`, where #45 is adopted | **the one that actually happens** |
224
+
225
+ The second costs one read per closed issue (its comments, where the `pharos:v1`
226
+ trailer lives) and is why `--no-work-items` exists for when you do not need it.
227
+
228
+ ### `disagreements` is the part nothing else can produce
229
+
230
+ ```jsonc
231
+ {
232
+ "pullRequest": { "number": 123, "state": "MERGED", "merged": true,
233
+ "review": "APPROVED", "checks": "FAILURE",
234
+ "failingChecks": [ { "name": "e2e-tests", "conclusion": "FAILURE" } ] },
235
+ "closesIssues": [45],
236
+ "workItems": [ { "id": 4821, "type": "Issue", "state": "To Do", "title": "…" } ],
237
+ "disagreements": [
238
+ "#4821 is \"To Do\" but this pull request is already MERGED — the work shipped and the board never moved."
239
+ ],
240
+ "problems": []
241
+ }
242
+ ```
243
+
244
+ Azure DevOps cannot see the pull request and GitHub does not know the work item
245
+ exists, so **neither system will ever report this**. An empty `disagreements`
246
+ means they agree; it is silent when there is nothing to say.
247
+
248
+ Three things about the shape:
249
+
250
+ - **`checks` is the rollup and it is authoritative. `failingChecks` names the
251
+ red ones** — found by filtering every context, not by showing the first few.
252
+ Measured: real pull requests report a `FAILURE` rollup while their first
253
+ several contexts all read `SUCCESS`, because the red one is further down.
254
+ - **`NONE` is not `SUCCESS`.** A pull request with no checks configured reports
255
+ `NONE`, and a still-running one is `PENDING`, not a failure.
256
+ - **`state` has three values** — `OPEN`, `CLOSED`, `MERGED`. Merged is its own
257
+ state, not a kind of closed, and `merged: true` is the field to test.
258
+
259
+ Posting a comment on a pull request is still `gh`'s job, exactly as it is for an
260
+ issue. This verb reads; it does not write.
261
+
262
+ ## Finding where it is WRITTEN: `pharos search`
263
+
264
+ `query` answers *which work items*. `search` answers *where has this been
265
+ written about* — and they are not the same question.
266
+
267
+ ```bash
268
+ pharos search "retry backoff" # work items AND wiki, one call
269
+ pharos search retry backoff # quoting is optional
270
+ pharos search "sprint policy" --wiki # pages only
271
+ pharos search "flaky" --items --type Bug # work items only, filtered
272
+ pharos search "onboarding" --org # the whole organisation
273
+ ```
274
+
275
+ **This is the only way to reach text nobody linked.** `pharos task <id>` returns
276
+ the content of every wiki page **linked** to the item — and you can only follow
277
+ a link somebody already made. A design written on a page and never attached to
278
+ the work item is invisible to every other verb here. That is what this finds.
279
+
280
+ It is also full text. `query --wiql … CONTAINS` matches **titles**; this matches
281
+ descriptions, the discussion, and page bodies.
282
+
283
+ ### What it returns
284
+
285
+ ```jsonc
286
+ {
287
+ "terms": "retry backoff",
288
+ "scope": "project", // or "organization" with --org
289
+ "workItems": {
290
+ "count": 97, // the TRUE TOTAL, not what was returned
291
+ "returned": 25,
292
+ "results": [
293
+ { "id": 373, "type": "Issue", "state": "Doing",
294
+ "title": "…", "assignedTo": "Ada Lovelace", "tags": ["api"],
295
+ "matched": [ // WHY this matched — the line itself
296
+ { "field": "System.Description",
297
+ "text": ["the backoff doubles each attempt"] } ] }
298
+ ]
299
+ },
300
+ "wiki": {
301
+ "count": 8, "returned": 8,
302
+ "results": [
303
+ { "path": "/Roadmap.md", "wiki": "Contoso.wiki",
304
+ "matched": [ { "field": "content", "text": ["…one page per feature."] } ] }
305
+ ]
306
+ },
307
+ "more": "Showing the first 25 of each…", // only when count > returned
308
+ "problems": []
309
+ }
310
+ ```
311
+
312
+ Four things about that shape, because guessing any of them costs a failed parse:
313
+
314
+ - **`count` is the total, `returned` is what you got.** They differ constantly —
315
+ a broad term reports 97 and hands you 25. Reporting `count` as "results I can
316
+ see" is wrong; reporting `returned` as the total is worse.
317
+ - **`matched[].text` is plain text.** The API wraps hits in `<highlighthit>`
318
+ markers; they are stripped before you see them.
319
+ - **Field names are normalised to `System.Description`**, the casing everything
320
+ else here uses. The endpoint itself answers `system.description`.
321
+ - **A surface that failed lands in `problems[]`** and the other still returns —
322
+ a token that reads the wiki but not work items gets the wiki half. If
323
+ `problems` is non-empty, say so before reasoning from the result. Both halves
324
+ failing is a non-zero exit, not an empty answer.
325
+
326
+ ### Code search is NOT here
327
+
328
+ It needs the Code Search extension installed on the organisation, which a token
329
+ cannot grant. Asking for it would return an empty result that means "not
330
+ provisioned" rather than "no matches", so the verb does not offer it at all.
331
+ `pharos doctor` reports whether this organisation has it.
332
+
191
333
  ## Changing a work item
192
334
 
193
335
  ```bash