# Pharos

> 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, 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.

- Skill: `asnnetworks/pharos` (Agent Skill)
- Install (CLI): `npx skillmds@latest add asnnetworks/pharos`
- Raw SKILL.md: https://api.skillmd.com/api/skills/asnnetworks/pharos/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Product & Planning
- License: Proprietary
- Author: ASNNetworks (https://skillmd.com/u/asnnetworks)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/asnnetworks/pharos

---


# Working Azure DevOps with `pharos`

`pharos` is a CLI that gives you Azure DevOps from a shell, authenticated by an
environment variable rather than a browser login.

**Everything it can do is listed below. Do not run `pharos --help` to find
out** — that costs several calls and this section is the same information.

```
whoami                         who ADO_PAT belongs to. @Me resolves to this
types                          what --state and --type will ACCEPT, per type
query                          WHICH work items — assigned to you, in a sprint,
                               of a type, still open. Hydrated items, not ids.
search <terms...>              WHERE something is WRITTEN — full text across
                               work items AND wiki pages, in one call. Reaches
                               descriptions, discussion and page bodies, which
                               `query` cannot. Each result carries the MATCHING
                               LINE. --items / --wiki to narrow, --org for the
                               whole organisation, --top (default 25)
task <id>                      one work item, whole: fields, comments,
                               attachments, relations WITH titles, and the
                               content + discussion of every linked wiki page
                               --children  an EPIC and everything under it, in
                               ONE call. --depth <n> for deeper (max 5)
                               --compact   flatten identities, drop board keys
create <type> --title "…"      one work item. --parent goes in the SAME patch
update <id>                    change a field: --state --priority --assignee
                               --title, or --field Name=value for anything else
                               --expect-rev <n> pins a revision you read earlier
link <id> --parent <id>        relate two items. Also --child --related
unlink <id> --parent <id>      --predecessor --successor --duplicate
link <id> --wiki-page <path>   link a WIKI PAGE to a work item — the item side
wiki links <path>              which work items link a page (ADO has no API)
wiki link <path> --item <id>   the WIKI side; --item repeatable
wiki unlink <path> --item <id>
history <id>                   what CHANGED, field by field, who and when
iterations | areas             the sprints with their dates, and the areas
links                          every relation type this ORG has, and which
                               --flag reaches it (six of ~eighteen)
fields [--type T]              what a field will ACCEPT — allowed values
attach <id> <file>             put a FILE on a work item (Attachments list)
detach <id> <url-or-guid>      take one off. --yes. Or --name <file>
download <item-id|guid|url>    read one back. A WORK ITEM id with --name <file>,
                               or --all --out <dir>. --out or nothing is written
image <file>                   upload a PICTURE for use inside text. Prints the
                               markdown to paste. NOT the same as attach
people [query]                 who can be @mentioned, with the @<guid> form
delete <id> --yes              → Recycle Bin (no permanent delete, on purpose)
restore <id>                   bring one back
deleted                        what is in the Recycle Bin, with names.
                               --top <n> — it returns 50 by default and reports
                               the true total as "count"
wiki list | tree | read <path> | write <path> | delete <path>
wiki move <path> <new path>    move a page; sub-pages come with it
wiki rename <path> <new name>  the same call, leaf only. BOTH need --yes
wiki duplicate <path> [to]     a verb Azure DevOps lacks. "<path> - Copy N"
wiki image <file>              a picture for a PAGE or page comment. Different
                               endpoint from `image`, and the name is unique-d
wiki import <file...>          .md .txt .docx .pdf .rtf .html -> pages. A
                               .pptx is refused, on purpose — see below
                               --under <path> --as <name>
comment list | add | edit | delete   <target> is a work item id, a wiki path,
comment react | unreact | reactors   OR a GitHub issue (contoso/widgets#45)
comment hide | unhide | pin | unpin  GITHUB ONLY — Azure DevOps has neither
                               REACTIONS ARE NOT ONE VOCABULARY. Azure DevOps:
                               like dislike heart hooray smile confused. GitHub:
                               thumbs-up thumbs-down laugh hooray confused heart
                               rocket eyes. Passing one platform's name to the
                               other is refused, not translated
                               `comment add` is REFUSED on a GitHub issue — see
                               "Editing a GitHub comment" below
issue adopt <owner/name#45>    a GitHub ISSUE becomes a work item, with BOTH
                               ends of the link written. --type --title --parent
issue link <owner/name#45> <id>   join an existing pair — or finish a join that
                               half-happened. Only the missing half is written
issue say <owner/name#45>      ONE message, TWO audiences: the whole of it to
                               the reporter on GitHub, a summary and that
                               comment's URL to the board. --summary
issue trail <id|owner/name#45> the whole trail from EITHER end, with the
                               evidence for each half. Read-only
issue drift                    where the two platforms DISAGREE — the report no
                               other tool can produce. --repo --limit --wiql
issue backfill <owner/name>    bulk adopt every issue the board does not link
                               yet. Needs --yes. --limit --state --label
issue close <id|owner/name#45> close BOTH ends, then RE-READ both to say what
                               actually moved. --to --reason --all
issue edit <owner/name#45>     the issue's OWN fields, behind a lost-update
                               guard. Needs --yes. --title --text/--file/--stdin
                               --milestone --add-label --remove-label
                               --add-assignee --remove-assignee --if-title
                               --if-body. `gh issue edit` is the UNGUARDED one
hooks list | check | create | repoint | delete    service hooks for realtime.
                               `check` needs --hub <url>; `list` shows the URL
                               already in use
plan <file>                    an implementation plan → a work item tree.
                               The plan file writes Epic/Issue/Task and
                               To Do/Doing/Done; the run maps them to whatever
                               THIS project calls those roles
setup                          org, project, token → keychain + shell profile
                               --install ask|all|a,b  offer the optional
                               capabilities (LibreOffice, poppler, converter,
                               the GitHub CLI)
                               --repo owner/name --gh-account <login>  bind a
                               repository to the GitHub account that reaches
                               it. BOTH or NEITHER — `issue` needs this first
bridge show|labels             what an adopted issue BECOMES: which work item
                               type each GitHub label maps to. `labels --repo
                               owner/name` shows what is mapped, what an
                               unmapped one would default to, and which of
                               those it is
bridge map|unmap|digest        edit that mapping (--label is an ID, not a name
                               — ids survive a rename), and when a digest is
                               written back to the work item
doctor                         what is installed on THIS machine, what is
                               missing, and what each missing thing costs
```

Text input: `--text` / `--file` / `--stdin`. Global: `--pretty` for a human,
`--yes` for destructive verbs, `--dry-run` to preview, `--max-writes <n>` to
change the per-invocation write cap (default 20; `0` is read-only).

**`ADO_ORG`, `ADO_PROJECT` and `ADO_PAT` are already in the environment** after
setup. Do not check them before working; a missing one announces itself as
`"kind": "config"` on exit 2, which is the only time it matters.

**Reading an attachment needs tools this machine may not have**, and the
commands further down name them with confidence they have not earned on yours.
`pharos doctor` answers that in one call: what is present, its version and where
it resolved, what is missing, and what each missing thing actually costs. It is
read-only and works before anything is configured — so it is also the right
first move when something behaves oddly. Offline too, with one exception it
names: `gh auth status` validates every token against github.com and there is
no flag that stops it, so that probe runs only when `gh` is installed.

`pharos setup --install ask` then offers to install them, driving Homebrew, apt
or winget rather than vendoring anything. **Nothing installs without that flag**
— `--install all` for a fresh machine, `--install libreoffice,poppler` to
provision without a terminal.

`--install skill` installs THIS skill as a plugin from the floh-skills
marketplace. Worth knowing even if you are reading it: **publishing a new
version does not update an installed one.** `claude plugin update
pharos@floh-skills` does, and a restart loads it — so a correction can ship and
sit unread for weeks. If something here contradicts what the tool actually does,
check your version first.

## Finding the work: `pharos query`

**Do not reach for `curl` and the WIQL endpoint.** This skill used to hand you a
recipe for exactly that, because there was no query verb. There is one now, and
it does the part the recipe could not: WIQL returns **ids only**, so the recipe
gave you sixteen bare numbers and a call per item to make them mean anything.

```bash
pharos query --mine                       # assigned to you, still open
pharos query --sprint "Sprint 1"          # --sprint current for @currentIteration
pharos query --type Epic --state Doing    # both repeatable
pharos query --assignee "ada@contoso.com"
pharos query --tag api --all              # --all includes finished work
pharos query --wiql "SELECT [System.Id] FROM WorkItems WHERE …"   # escape hatch
```

Flags AND together. Output is hydrated items — id, type, title, state, assignee,
iteration, tags, priority, changed — so `--mine` is one command, not a query
followed by a fetch per result. Follow up with `pharos task <id>` only for the
few you are actually going to work on.

**Read the `openness` field before you report a count.** "12 open" is
meaningless until you know what was counted as finished, and Azure DevOps lets a
process template rename every state. `query` reads the project's own state
categories and tells you which states it treated as terminal; if it could not
read them it says so and falls back to guessing, and that is your cue to pass
`--state` explicitly. `assignedTo` names who `@Me` actually resolved to — a
shared or service token makes "assigned to me" quietly mean somebody else.

`--all` composes with every other filter, so `--mine --all` is how you tell
*nothing is assigned to you* from *nothing open*. A bare `pharos query --all` is
legal and returns the whole project.

`--sprint` takes either the bare name or the fieldPath and normalises between
them — the fieldPath rule is about `System.IterationPath` as a *field value*. But
a named sprint matches with `UNDER` (sub-iterations included) while `--sprint
current` matches with `=` (exact), so they are not the same query.

`--wiql` also accepts a `FROM WorkItemLinks … MODE (MustContain)` query and
hydrates the ids the same way, which is how you find every item carrying a given
relation type in one call.

Do NOT write `[System.State] NOT IN GROUP 'Completed'` if you reach for `--wiql`.
It parses, returns 200, and matches **everything** — `IN GROUP` covers work item
TYPE categories only, and an unknown group resolves to the empty set with no
error. Measured, 2026-08-05.

## Finding where it is WRITTEN: `pharos search`

`query` answers *which work items*. `search` answers *where has this been
written about* — and they are not the same question.

```bash
pharos search "retry backoff"              # work items AND wiki, one call
pharos search retry backoff                # quoting is optional
pharos search "sprint policy" --wiki       # pages only
pharos search "flaky" --items --type Bug   # work items only, filtered
pharos search "onboarding" --org           # the whole organisation
```

**This is the only way to reach text nobody linked.** `pharos task <id>` returns
the content of every wiki page **linked** to the item — and you can only follow
a link somebody already made. A design written on a page and never attached to
the work item is invisible to every other verb here. That is what this finds.

It is also full text. `query --wiql … CONTAINS` matches **titles**; this matches
descriptions, the discussion, and page bodies.

### What it returns

```jsonc
{
  "terms": "retry backoff",
  "scope": "project",                  // or "organization" with --org
  "workItems": {
    "count": 97,                       // the TRUE TOTAL, not what was returned
    "returned": 25,
    "results": [
      { "id": 373, "type": "Issue", "state": "Doing",
        "title": "…", "assignedTo": "Ada Lovelace", "tags": ["api"],
        "matched": [                   // WHY this matched — the line itself
          { "field": "System.Description",
            "text": ["the backoff doubles each attempt"] } ] }
    ]
  },
  "wiki": {
    "count": 8, "returned": 8,
    "results": [
      { "path": "/Roadmap.md", "wiki": "Contoso.wiki",
        "matched": [ { "field": "content", "text": ["…one page per feature."] } ] }
    ]
  },
  "more": "Showing the first 25 of each…",   // only when count > returned
  "problems": []
}
```

Four things about that shape, because guessing any of them costs a failed parse:

- **`count` is the total, `returned` is what you got.** They differ constantly —
  a broad term reports 97 and hands you 25. Reporting `count` as "results I can
  see" is wrong; reporting `returned` as the total is worse.
- **`matched[].text` is plain text.** The API wraps hits in `<highlighthit>`
  markers; they are stripped before you see them.
- **Field names are normalised to `System.Description`**, the casing everything
  else here uses. The endpoint itself answers `system.description`.
- **A surface that failed lands in `problems[]`** and the other still returns —
  a token that reads the wiki but not work items gets the wiki half. If
  `problems` is non-empty, say so before reasoning from the result. Both halves
  failing is a non-zero exit, not an empty answer.

### Code search is NOT here

It needs the Code Search extension installed on the organisation, which a token
cannot grant. Asking for it would return an empty result that means "not
provisioned" rather than "no matches", so the verb does not offer it at all.
`pharos doctor` reports whether this organisation has it.

## Changing a work item

```bash
pharos update 225 --state Doing              # move the state when the work moves
pharos update 225 --priority 1 --assignee "ada@contoso.com"
pharos update 225 --field Microsoft.VSTS.Scheduling.RemainingWork=3
pharos attach 225 ./bestsellers.xlsx --comment "The numbers"
```

`update` **reads the item and applies the change under a `test` op on `/rev`**,
so somebody who wrote between your read and your write gets you a `conflict`
rather than losing their edit. Confirmed live against real concurrent writers:
three rounds, one winner each round, the loser always a 412 `conflict` carrying
both revisions, and no lost update. **`--expect-rev <n>` pins a revision you
read earlier** instead of re-reading — reach for it when your read and your
write are separate calls with thinking in between, which is where a
read-modify-write actually goes wrong. Setting a value it already has writes nothing and
says so — a pointless PATCH still bumps `System.Rev` and invalidates every other
cached revision on the item. `--dry-run` shows the before → after and writes
nothing. There is no `--yes`: a field edit is an ordinary edit and Azure DevOps
keeps every revision.

`attach` uploads the bytes and then links them as an `AttachedFile` relation —
two calls, one command. Attachments are **immutable**: attaching the same file
twice makes two of them, and there is no replace and no versioning. `detach`
takes one off; it needs `--yes`, finds the attachment by identity rather than by
position, and leaves the bytes in Azure DevOps so re-attaching the url puts it
back. Name the file with `--name <file>` or pass the GUID — and as with
`download`, two files sharing a name REFUSE rather than removing whichever
sorts first.

`create` puts `--parent` in the same patch as the fields, so a child is never
briefly an orphan, and it takes the same flag names as `update`.

**`unlink` refuses rather than guessing, and that is worth knowing before you
see it.** Azure DevOps removes a relation by its POSITION in the array, so an
index from a stale read cuts a different link and the request still succeeds.
`unlink` finds the relation by identity and removes it under a `test` op on the
revision it read. If the relation is not there you get exit 3 — that means
nothing happened, not that the call failed.

`delete` moves to the **Recycle Bin** and needs `--yes`; the refusal quotes the
title first. `restore <id>` brings it back and needs no flag.

**There is no permanent delete here, deliberately.** It is the only irreversible
verb Azure DevOps has, and `--yes` is a flag you have learned to pass. The web
UI owns it. If somebody genuinely needs to purge, send them there rather than
looking for a flag.

## Pictures in text, and files beside it

**These are two different things and picking the wrong one is the mistake worth
avoiding.**

```bash
pharos attach 225 ./bestsellers.xlsx    # a FILE, in the Attachments list
pharos image ./screenshot.png           # a PICTURE, to put inside the text
```

An **attachment** creates an `AttachedFile` relation and appears in the work
item's Attachments list. An **inline image** creates no relation at all —
measured on #333, two screenshots pasted into a description gave `relations: 0`
and `attachments: []`. It is an `![](…)` in the field text and nothing else,
which is why the Attachments list is right to show nothing for it.

So `image` prints a markdown line and leaves the writing to you:

```bash
pharos image ./chart.png --pretty
# ![chart](https://dev.azure.com/…/wit/attachments/<guid>?fileName=chart.png)

pharos update 225 --field System.Description="$(cat <<'EOF'
Revenue is up. See the chart:

![chart](https://dev.azure.com/…/wit/attachments/<guid>?fileName=chart.png)
EOF
)"
```

**A wiki picture is a different endpoint and needs `wiki image`.** A wiki is a
git repository, so an attachment there is a FILE and its NAME is its identity:
upload a second `image.png` and it lands on the first, and every page pointing
at `/.attachments/image.png` silently changes picture. `wiki image` makes the
name unique before sending and prints a repo-relative link:

```bash
pharos wiki image ./diagram.png --pretty
# ![diagram](/.attachments/diagram-1786046773042.png)
```

Use that path, **not** an absolute url — a page linking to a `wit/attachments`
url renders for anyone with a session and breaks for everybody else. The same
markdown works in a page and in a page comment.

## Getting an attachment off a work item

`pharos task <id>` lists what is attached, with each file's **GUID** — in the
JSON and in `--pretty`. Then take it in one call, by name:

```bash
pharos download 41 --name Skills.pptx --out ./Skills.pptx
pharos download 41 --all --out ./attachments      # every attachment on the item
pharos download <guid-or-url> --out ./file.bin    # when you already hold one
```

**`--out` is what writes.** Without it you get the size and no file — raw bytes
on stdout would corrupt the JSON every other verb prints.

A bare `pharos download 41` is a usage error **carrying the list** — name, GUID
and size — so choosing the right file never costs a second call. Two attachments
with the same name refuse rather than guess: attachments are immutable, so
attaching a file twice makes two of them and the name is not an identity.

`detach` takes the same `--name <file>`, so the verb you reach for after reading
`task --pretty` accepts what it showed you:

```bash
pharos detach 41 --name Skills.pptx --yes      # --dry-run previews it first
```

### Reading what is inside it is YOUR job, with YOUR tooling

`download` gets you bytes on disk and stops there. Turning a `.docx`, `.pptx`,
`.xlsx` or `.pdf` into something you can read is your environment's job.

**Do not use `pharos-convert` for it.** It is `wiki import`'s converter, shared
with the macOS app so that both produce the same *page* from the same file — its
output is shaped to become a wiki page, and reaching for it here couples what you
read to the app's import path. It also cannot read `.pptx` at all.

Two ways this fails **silently**, both measured on a real work item:

- **You checked for the tool in the wrong place.** `which markitdown` against
  the system PATH and `import docx` against the system `python3` both come back
  empty on a machine where that tooling is installed — in a per-skill venv. An
  agent that runs those two checks concludes "nothing here" and routes around
  tools that were there the whole time. Look where *your* agent keeps its
  tooling before concluding it is absent.
- **Non-empty text is not proof you read the document.** One real `.pptx`
  extracted to seven fragments, about 90 characters; the entire specification
  was in two embedded PNGs. Every text-only reader returns something for that
  deck and looks like it worked. For a slide deck, or a scanned PDF, the payload
  is usually the images — extract them and actually look at them.

### First ask whether you read PDFs natively. If you do, this is one command.

Many agents — Claude Code among them — read a PDF **visually**, page by page,
the way a person looks at it. That covers a scan with no text in it at all, with
no OCR step. If that is you, the whole problem collapses to one conversion:

```bash
soffice --headless --convert-to pdf f.docx --outdir ./out    # .pptx, .xlsx too
# then read ./out/f.pdf with your own file-reading tool
```

**This is the route that keeps the pictures**, which is the whole failure this
section is about. Measured on the deck described above — the one whose seven
text fragments lost the specification: converted to PDF and read, it gives up
the flow diagram, the screenshot of the configuration UI with its actual
threshold values, and every row and column of the target output table. One
command, one read, nothing dropped. The same is true of a `.docx` whose content
is in a chart or a screenshot.

Prefer it whenever layout or images might carry meaning — a deck always, a
report usually, a spreadsheet when the shape matters more than the numbers.

### If you only read text, extract it per format

| file | how |
|---|---|
| `.pdf` | `pdftotext -layout f.pdf -` — poppler; keeps the table layout. **Empty output means a scan**, not an empty document |
| scanned `.pdf` | `pdftoppm -png -r 150 f.pdf page`, then look at the PNGs it wrote |
| `.docx` | `soffice --headless --convert-to "txt:Text (encoded):UTF8" f.docx --outdir ./out` — tables come out tab-separated |
| `.xlsx` | `soffice --headless --convert-to csv f.xlsx --outdir ./out` — **first sheet only**. Count them first: `unzip -p f.xlsx xl/workbook.xml \| grep -o '<sheet [^>]*name="[^"]*"'` |
| `.pptx` | `~/.claude/skills/pptx/.venv/bin/python -m markitdown deck.pptx` — slide text. Its `![](Graphic5.jpg)` lines are SHAPE names, not files: see below |
| images inside any of them | `unzip -o -q f.pptx 'ppt/media/*' -d ./out` — also `word/media/` in a `.docx`, `xl/media/` in an `.xlsx` |

Two traps in that table, both measured:

- **Do not predict what `pdftoppm` names its output.** The page number is padded
  to the width of the page COUNT, so a one-page scan — what an attachment
  usually is — gives `page-1.png` while a forty-page one gives `page-01.png`.
  List the directory. A guessed name that is not there reads as "the render
  failed" when it worked.
- **`--convert-to pdf` paginates a spreadsheet twice over, and only one of them
  is obvious.** Long splits by ROW, which is ordinary. **Wide splits by
  COLUMN** — a 40-column sheet became four pages, each carrying a different
  slice of the columns for the same rows. So a page count above one does not
  tell you which kind you have: read every page, and if a row looks like it is
  missing fields, look for them on the next one. For pure numbers `csv` is the
  better half of the pair.

**Count the parts before you trust a conversion.** Every one of these formats is
a ZIP, so the file itself will tell you what it holds — and each of these has
been the thing that was quietly missing:

```bash
unzip -p f.xlsx xl/workbook.xml | grep -o '<sheet [^>]*name="[^"]*"'   # sheets
unzip -l f.pptx | grep -c 'ppt/slides/slide[0-9]*\.xml'                # slides
unzip -l f.docx | grep 'word/media/'                                   # images
```

If the images list is empty, text extraction loses nothing and the cheap route
is safe. If it is not, that is your warning that the payload may not be text.

**`markitdown`'s image lines name SHAPES, not files.** A deck that emits
`![](Graphic5.jpg)`, `![](Graphic8.jpg)` and `![](Graphic9.jpg)` for one slide
turned out to contain exactly two media files in the whole archive — named
`image1.png` and `image2.svg`, matching none of them. Those are PowerPoint's
shape names. Do not go looking for a file by one, and do not read three of them
as three pictures. To map media to the slide that uses it, read the
relationships: `unzip -p f.pptx ppt/slides/_rels/slide6.xml.rels`.

**Text extraction tells you WHICH strings are on a slide and never WHERE.** That
is not a nuance — measured on a real deck, one slide carried both `IDENTITEIT`
and a leftover `Wat is ChatGPT?` from a different presentation, in the same
place, printing on top of each other. Extracted, they are two tidy lines and
read as a title with a subtitle. Rendered, the slide is visibly broken. So a
duplicated, stale or overlapping shape is invisible to every text route by
construction — if you are reviewing a deck rather than mining it for facts, look
at it.

`pandoc` is **not** installed here, whatever another skill's instructions say.
`markitdown` in that venv does `.pptx` and nothing else: it went in without the
`[docx]`, `[pdf]` and `[xlsx]` extras and raises `MissingDependencyException`
for all three. That is why LibreOffice, not markitdown, is the line above for
everything except a deck.

**Known-good on a Claude Code machine provisioned by us, measured 2026-08-08.**
Conditional on purpose: this skill also runs under other agents on machines
nobody here set up, so read a missing command as "find your own", not as a bug.

## Mentioning somebody

**A mention is `@<guid>` and nothing else notifies.** `@Ada Lovelace` written
into a comment is plain text: it reads like a mention to every human who sees
it, links to nobody, and sends no notification. Nothing errors, so this fails
silently and stays failed.

```bash
pharos people                     # everyone the board knows, with the form
pharos people ada                 # filter by name or email
pharos comment add 225 --text "Ready for review @<0f45a818-878d-6d7a-ba8c-1f5568a89ed4>"
```

**A mention only works in a COMMENT.** Measured live: the same `@<guid>` written
into `System.Description` is stored as literal text — no anchor, no mention
registered, nobody notified. Azure DevOps parses that shorthand on the comments
endpoint and nowhere else. So a mention put in a description or any other field
fails in precisely the silent way this section exists to prevent: it reads like
a mention to every human who sees it and reaches no one.

If you need somebody told, post a comment. Editing a field is not a substitute —
and note that **every work item write here suppresses notifications on purpose**
(`update`, `create`, `attach`, `detach`, `link`, `unlink`), so an agent doing
bookkeeping does not mail the assignee about each step. `comment` is the one
write that notifies.

The names come from the board's own work items — everyone assigned, creating or
changing anything — rather than from an identity endpoint, because those live on
another host and want scopes a work-scoped PAT does not have. So somebody who
has never touched an item here will not be listed; the guid out of any Azure
DevOps url works just as well.

## Turning documents into wiki pages

```bash
pharos wiki import ./notes.md ./spec.docx --under "/Guides"
pharos wiki import ./report.pdf --as "Q3 Report"
```

`.md` and `.txt` are copied **verbatim** — they are already the target format,
and anything done to them would be reformatting a document somebody wrote
deliberately. `.docx`, `.pdf`, `.rtf` and `.html` are converted by
`pharos-convert`, which is the **same converter the macOS app uses**, so both
produce the same page from the same file.

**It is a separate download, and not everybody has it.** Without it, markdown
and text still import and everything else is refused by name with the reason —
narrowed, not broken. `pharos doctor` says whether this machine has it;
`pharos setup --convert` fetches it (macOS only for now).

Three rules, because each of them is a way to lose work:

- **A derived name never collides — it COUNTS UP.** Importing `Notes.md` when
  `/Guides/Notes` already exists creates `/Guides/Notes 2`, reports
  `created: 1, skipped: 0`, and exits 0. Your page is never overwritten, which
  is the property that matters. But **a blind re-import does not fail, it
  quietly accumulates** `Notes`, `Notes 2`, `Notes 3` — and every run reports
  complete success. If you are re-running an import, check the wiki first.
- **`--as <name>` is the one that SKIPS.** A name you typed is an instruction,
  so it is never renamed: onto an existing page it skips, says so, and exits 3
  when nothing else landed. Use it when you want a collision to stop you.
- **Names are settled against the batch as well as the wiki**, so importing
  `Notes.docx` beside `Notes.pdf` gives two pages rather than one written twice.
- **An empty document is refused.** A scanned PDF carries no extractable text at
  all and PDFKit returns an empty string with no error; an empty page would look
  like a successful import until somebody opened it.
- **A `.pptx` is refused by name**, and that is the answer rather than a gap.
  `pharos-convert Skills.pptx` exits 2 with *"Skills.pptx is not a kind of file
  this can import."* **Do not route around it** by extracting the slide text
  yourself and importing that: a deck's payload is usually its images, so the
  text-only page looks like a successful import and has lost the content. The
  empty-document rule would not catch it either — the text is short, not empty.
  If the deck must become a page, read it (above) and write the page yourself.

Exit 3 when **nothing** landed. A partial batch exits 0 and names what skipped.
Do not retry a partial batch blindly: the files that succeeded will import a
second time under counted-up names, so you end with duplicates and an exit 0
saying it all worked. Re-run only the files that skipped.

## Do not guess a state name — ask

```bash
pharos types                 # every type, its states, and which mean "finished"
pharos types --type Task
```

**`update` now refuses a state the item's type does not have, before writing,
with the valid ones attached** — so you rarely need to run this first. Run it
when you want to see the shape, or when composing a `--wiql` filter.

**States belong to a TYPE, not to the project.** A state that exists elsewhere
is still not one this item can take: `In Progress` is real on a Test Suite and
invalid on a Task, and checking the project as a whole is the same guess one
layer down. The categories are shown as well as the names because "which
states exist" and "which mean finished" are different questions — `Inactive` is
finished on a Test Plan and appears in nobody's hard-coded Done/Closed/Removed
list.

**Nothing in this CLI writes a state name it was not told.** `query` reads the
categories to decide what "open" means, `update` refuses a state the type does
not have, `issue close` moves an item into the project's own terminal state,
`issue adopt` and `issue backfill` resolve the work item TYPE by role, and
`plan` maps its file's `Epic`/`Issue`/`Task` and `To Do`/`Doing`/`Done` onto
whatever this project calls those roles. Where any of them cannot read the
project it falls back to the stock names **and says so** — `source: "fallback"`,
a `note`, or a warning on stderr. If you see one, the answer is a guess.

`pharos whoami` is the other one worth reaching for early: it names the identity
behind `ADO_PAT`, which is who `@Me` resolves to and who every write is
attributed to. A shared or service token quietly makes "assigned to me" mean
somebody else. It is org-scoped, so it still answers when the project is
misconfigured — which is exactly when you need it.

## Ask, do not guess — the five discovery verbs

Every one of these replaced a guess, and a guess that silently succeeds against
the wrong value is worse than one that fails:

```bash
pharos history 39            # what changed on it, field by field, who and when
pharos iterations            # the sprints, with start/finish dates
pharos areas                 # the area tree
pharos links                 # every relation type, and which --flag reaches it
pharos fields --type Task --constrained   # what Priority and Activity accept
```

**Which sprint is current comes from the TEAM, not from the dates.**
`iterations` reports a top-level `current` (the field path of the team's current
iteration) and marks that node `current: true`. It asks team settings, because
an iteration is current because a team says so — `startDate`/`finishDate` are
routinely null and deriving it from them answers "no current sprint" on most
boards. `query --sprint current` resolves the same iteration.

**`iterations` prints TWO paths and only one of them works as a field value.**
`path` is the classification node — `\Tibata\Iteration\Sprint 1`. `fieldPath`
is what `System.IterationPath` and `--sprint` take — `Tibata\Sprint 1`, with no
`Iteration` segment. Handing the node path to the field is a 400 that reads as
though the sprint does not exist. A sprint with `startDate: null` is normal —
most orgs never set them.

**`history` returns raw field values.** The top-level `by` is a flattened
display-name string, but `changes[].from`/`to` are the field values themselves —
an identity field gives you the whole identity object, not a name. `--compact`
is a `task` flag and does not exist here, and `WEF_…` board keys are not
filtered, so expect them on the creation revision.

**`history` reads `/updates`, which is the diff.** `/revisions` is snapshots you
would have to diff yourself. Bookkeeping fields that change on every revision
(`System.Rev`, the dates, the watermark) are filtered out unless you pass
`--all`; a revision that changed only those is dropped entirely, because it is
not a change anybody made.

**`links` exists because `link` names six kinds and an org has about eighteen.**
`Affects`, `TestedBy`, the `Remote.*` family and `Duplicate-Reverse` have no
flag. The output marks which ones do, so "does this link type exist" and "can I
make it from here" are one answer.

There is **no `--rel <referenceName>` escape hatch** — the six flags are the
whole write surface. That has one consequence worth knowing before you promise
it: `--duplicate` reaches `Duplicate-Forward` only, so if the item you are
standing on shows the relation as **Duplicate Of** (`Duplicate-Reverse`), unlink
it from the *other* item instead.

**`--constrained` narrows `fields` to those with an allowed-values list** — the
ones you can get wrong. Without it you get every field on the type. Note that
Priority's `allowedValues` come back as STRINGS (`"1"`…`"4"`) while `--priority`
takes the number: do not quote it on the command line.

**`fields` needs `--type`.** Allowed values belong to the TYPE, not the project
— `Activity` is on `Task` and on neither `Epic` nor `Issue` in the Basic
process, so there is no project-wide answer to "which fields are there".

## Wiki pages and work items: one relation, two directions

**This is what makes a plan findable.** `pharos task` reads linked wiki pages and
their discussion — that is the whole point of it — and the link is what puts them
there.

```bash
# from the ITEM: one task, the documents it needs
pharos link 39 --wiki-page "/Plans/Sprint 3"
pharos unlink 39 --wiki-page "/Plans/Sprint 3"

# from the PAGE: one spec, the ten tasks that implement it
pharos wiki link   "/Plans/Sprint 3" --item 40 --item 41 --item 42
pharos wiki unlink "/Plans/Sprint 3" --item 40

# and the question Azure DevOps has no API for
pharos wiki links "/Plans/Sprint 3"
```

**A wiki page stores nothing about work items.** Measured: a page resource is
`path`, `order`, `gitItemPath`, `subPages`, `url`, `remoteUrl`, `id` — no link
field at all. The relation lives on the WORK ITEM, and Azure DevOps' own "Link
work items" panel on a page is a reverse lookup. That has two consequences worth
knowing before you plan a call:

- **item → its pages is FREE.** They are already in the item's own relations, so
  `pharos task <id>` returns them with no extra request. There is no such thing
  as a reverse lookup on a work item, and nothing scans the wiki.
- **page → its items costs one call.** `wiki links` is the only direction that
  has to ask.

Either way the other side sees it: link ten items from the page, and each of the
ten now returns the page from `task`.

`--wiki-page`, **not** `--wiki`: `--wiki <name>` is the global flag naming which
wiki to work in, and using it here means "the wiki called /Plans/Sprint 3".

`wiki link` writes one relation per item and reports one result per item — a
partial failure is a real outcome when ten items are named, and collapsing it
into one ok/failed would be a lie about the other nine.

### The URI is the identity, and it has a trap in its history

`vstfs:///Wiki/WikiPage/<projectId>%2F<wikiId>%2F<path>`, the path carrying **no
leading slash**. Two things follow:

- **Moving or renaming a page silently breaks every link to it**, because the
  path was the identity. That is why `wiki move` and `wiki rename` need `--yes`
  and `wiki write` does not.
- Before pharos-cli 0.16.0 this tool wrote `%2F%2Fpath` — one extra encoded
  slash. Those links are real, Azure DevOps stored them, and `task` reads them
  back fine, but the wiki's own panel could never find them because its reverse
  lookup keys on the canonical form. If a page shows a link here and not on the
  website, that is why. `wiki links` and `unlink` both ask about **both** shapes,
  so old links still resolve and can still be removed.

## GitHub issues: the join, and only the join

**These verbs are newer than the CLI on most machines. Check before you promise
one.** `pharos issue` shipped *after* **0.23.0**, so if `pharos --version` prints
0.23.0 or lower it is not there and every verb below is an unknown command —
`npm i -g @floh-solutions/pharos-cli@latest` is the fix. This skill 

…(truncated)
