Compare commits

...
14 Commits
Author SHA1 Message Date
jeffryandClaude Opus 5 d7b169b87e a 404 at adoption has a third reading, and the tool can resolve it
externals names two readings of a 404 — withdrawn, or access lost — and those are
the two a locked document can have. Adoption by name has a third: a document that
was never there under that name. Found in use, where a missing s reported the
ambiguity instead of the typo.

The tool was reporting an ambiguity it had the means to resolve: the by-name form
knows the repository, so on a 404 it now lists the published surface and says which
names exist. It claims that only when the listing succeeds — if listing fails too,
the repository is unreachable and the original ambiguity is the honest answer,
which is the same discipline as recording public and not-public rather than
private.

Not filed as a gap against externals. The third reading cannot occur where that
document is speaking, which is check against a lock; it exists only at adoption,
which is ours.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018UTxuSizozEA8yDitPuris
2026-09-08 12:02:44 -04:00
jeffryandClaude Opus 5 a5093c1efc compact the event log: six dropped, fifty kept, and the result is the finding
recording-decisions now permits compaction and names four kinds that may never be
dropped. Applying it removed six entries and left fifty, and the ratio is worth
more than the saving.

Every entry dropped became droppable by being published. what this tool is, the
lock format, unlocked is a state, --help as the documentation surface, and the
visibility field all now live in .loom/published/guarantees.md or README.md, and
conversion-destroys lives in cart/v2 and cart.usages.md. Nothing was summarised
away — the content moved into documents somebody else can lock, which is a stronger
place for it than a log only we read. The entry names each one and where it went,
so the compaction is auditable rather than trusted.

What did not compress is the point. Almost everything left is a decline, a
measurement, a supersession, or a belief shown wrong. Declines are not recoverable
from code because the code is what they are absent from; measurements are facts
about other people's systems that no artifact here holds; supersessions are changes
of mind, which is most of what a reader wants. The log is long because the work was
exploratory, not because it was padded.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018UTxuSizozEA8yDitPuris
2026-09-08 10:49:41 -04:00
jeffryandClaude Opus 5 045924ec36 tansy converts: three entries, no shelf
Publishes what the tool guarantees, on a condition that fired rather than a
decision reversed. Records the narrower promise that replaced promising the command
surface — a change to it lands on the page consumers already lock, so a rename
reports 200 the day it happens.

And records a limit found by accident that follows from a guarantee rather than
from anybody's code: an ETag is opaque, we promise never to interpret one, so two
of them can be compared and never ordered. A consumer can know two versions
disagree and can never know which is behind. Anybody building freshness on this
inherits it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018UTxuSizozEA8yDitPuris
2026-09-08 10:48:47 -04:00
jeffryandClaude Opus 5 d3fec24bc6 guarantees: promise visibility about the surface we do not promise
loom's .usages.md for this page found the hole. The docs site depends on the
command surface — its README and publish script tell a person to type loomctl
external add loom/<repo> <doc>.md — and this page explicitly does not promise it.
Nothing parses the command, which is exactly why no check would catch a rename:
the thing that moved is not a document anybody adopted.

The answer is not to promise the surface, which would freeze the CLI. It is to
promise that a change to it lands on this page — which consumers already lock — so
a rename reports 200 the day it happens. The exclusion goes from "you are on your
own" to "you will be told", and it costs one append-only section.

Same shape as a supersession in the event log: the record is not that the thing
never changes, it is that the change is findable by whoever depended on it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018UTxuSizozEA8yDitPuris
2026-09-08 10:41:29 -04:00
jeffryandClaude Opus 5 4e7aa1ff1f loom-cli publishes one page: what the tool guarantees, not what it does
The earlier decision stands rather than reverses. .loom/published/ was recorded as
deferred, not declined — it earns publication when somebody depends on the tool's
behaviour rather than reading its help — and a docs site whose build calls add,
check and apply is that consumer. The condition fired.

The delta is real and is not the command list: --help says what the tool does, and
a reader cannot learn from it which behaviours survive the next version. The
sharpest case is loom's — the tool is now a second holder of externals' rule that
the lock is the publisher's ETag and never one you compute, and nothing anywhere
said the tool holds it, in a repository where a specimen was written that computed
a hash instead.

Includes what a contract must include and the ask did not mention: what is not
promised. The command surface, the orientation file's format across versions, the
precision of the visibility signal, and that anything is checked when nobody is
looking. That is the half a reimplementation needs most, and it keeps the page from
quietly committing us to the CLI.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018UTxuSizozEA8yDitPuris
2026-09-08 10:33:14 -04:00
jeffryandClaude Opus 5 af8f739bfe sorrel converts: four entries, no shelf
Extracted at conversion rather than staged as it went — there was no wal this
round, and the answers lived in the daily that the rm destroys, which is precisely
the failure v2 added the rule to prevent. Worth noticing that the round which
answered a question about durable records is the one that nearly lost its own.

Records two declines with their reasons: a second orientation file, since the
publishing half varies not at all and is already adopted so it appears in the index
by itself; and .loom/externals/README.md as the path, measured rather than argued,
because check walks that directory and would report a generated file as unlocked in
every repository using the feature, forever.

Records the visibility field with the word it refuses — public or not-public and
never private, because an anonymous request tells those apart and nothing finer,
and private would claim precision the signal does not have.

And records a one-line bug whose trap is permanent: visibility was computed by one
function used both after a successful fetch and as a probe, where "no credential
configured" means opposite things. In the audit it would have silently cleared real
alarms, which is the worst failure available to a check.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018UTxuSizozEA8yDitPuris
2026-09-08 09:25:56 -04:00
jeffryandClaude Opus 5 b3617ed195 the lock records what the source could be read as, and check audits it
Access is verified once, at fetch, and the copy is durable — so whether an
adoption is still legitimate rests on the relative visibility of two
repositories, which somebody can change with a checkbox a year later without ever
seeing the adoption. The lock gains an optional fourth field and check turns that
from a silent permanent hazard into something that runs.

It costs nothing at add time, because the anonymous request already happened to
decide whether to warn and the answer was being thrown away, and one request per
run at check time rather than one per document, because only our own visibility
has to be current. The stored value decays in both directions, so a source
recorded not-public is re-probed only when the alarm would fire, and a source that
has since gone public updates the lock and says nothing.

Fixes a bug found while testing the alarm rather than after shipping it.
sourceVisibility returned public whenever no credential was configured, which is
sound at add time — the fetch had just succeeded anonymously — and wrong in the
audit, where it is a probe and not a fetch: it would have silently cleared real
alarms. Probing is now its own function that always asks with no credential,
because what matters is what a stranger can read and not what we can.

The value is recorded as public or not-public and never private: an anonymous
request tells those apart and nothing finer, so it cannot see two repositories
private to different people, which is the case that genuinely widens access.

Three-field locks still load, and unknown visibility round-trips as absent rather
than as a value.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018UTxuSizozEA8yDitPuris
2026-09-08 09:22:42 -04:00
jeffryandClaude Opus 5 da6e8b9b51 loomctl orient: one table of contents, restating no rule
Generates .loom/orientation.md — what this repository depends on, where each copy
came from, and which facets sit beside it — for whoever arrives next, of any make.

One file rather than two, decided by loom's own rule. Generate what varies, adopt
what does not: the publishing half varies not at all and is already adopted, so
publication.md appears in the index like any other adopted document, in exactly the
repositories that adopted it. A second command would emit a file whose whole content
is a pointer to a file already in the tree, and would revive a noun declined when
published check went.

Restates no rule. The three moves are phrased as operations — what to run, what to
write, where — and every rule stays in the document that owns it. The .usages.md is
pointed at rather than summarised, because what depends on a document is free prose
and anything extracting a claim from prose eventually extracts it wrong. It says
nothing recorded when a document has no usages file, which is a finding rather than
an omission and is currently true of six of nine.

Byte-deterministic, pinned by a test that inserts records out of order and
generates three times. Written to .loom/orientation.md rather than inside
.loom/externals/, where check would report it as an unlocked external in every
repository using the feature.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018UTxuSizozEA8yDitPuris
2026-09-08 09:11:04 -04:00
jeffryandClaude Opus 5 1cc528c8aa sorrel: reconcile bedrock 82 and 83, and answer both prompts
publication.md and starting.md both came back 200 and are applied. Step 4 —
fetch it, from somewhere that is not your machine and not your account — is now the
procedure our declined command was an argument for.

Answers the Select for a fixed path but against .loom/externals/README.md, with a
measured reason: check walks that directory for adopted documents, so a generated
file there reports as unlocked in every repository using the feature, forever. The
deeper reason is the invariant — everything under externals/ is somebody else's
document, which is what makes do-not-edit a rule you can state in four words.
Proposes .loom/orientation.md, beside event-log.md, since .loom/README.md is
already given to what is in published/ and why.

Answers the Question yes. The visibility check was built as a moment and loom is
right that it is a fact that decays: access is verified once and the copy is
durable. It costs nothing at add time, since the anonymous request already happens
and the answer is thrown away, and one request per run at check time rather than per
document, since only our own visibility must be current. Adds the refinement that
the stored value decays both ways, so a source recorded private is re-checked only
when the alarm would fire.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018UTxuSizozEA8yDitPuris
2026-09-08 09:05:38 -04:00
jeffryandClaude Opus 5 fb8b8d01d3 rowan converts: four entries, no shelf, nothing blocked
All three prompts answered and extracted, the polad tree empty, and check clean
before the rm — which under v2 is the audit that has to happen, because the cart is
the only copy of everything in it.

The round's substance: three gaps closed at reconciliation, and in every case what
survived was a local workaround becoming specified behaviour — wal.md, which v2
named and cited our own sentence as the reason for; "a fetch you did not perform",
which had been a declined command and an insight without an implementation; and the
compaction rule, which came back with an addition we had not thought of.

Declines fixing annotating, because two of its four gaps are not gaps in the
agreement but a rule living in a local editor protocol instead of the document, and
deciding where a rule lives is not an amendment and not ours.

Records that a casting file goes stale on amendments that add no role — twice in
one day, v1 and v2 — and that the mechanism fired correctly both times and can only
point. And declines opening a round on the third-file question, on the grounds that
ours arrived as a conversion-timing failure the agreement already forbids, so it may
be a symptom rather than a category.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018UTxuSizozEA8yDitPuris
2026-09-08 08:56:14 -04:00
jeffryandClaude Opus 5 fb96b2f90b the private path is exercised, and the guardrail stops crying wolf
Closes the last unexercised assumption with a throwaway token: partial clone over
HTTPS with a token against a private repository, list through the tool, the
short-form raw URL redirecting to a resolved branch under auth, and a conditional
request returning 304 across that redirect. A private repository can adopt from
another private one, by name or by URL, and check works off the lock afterwards.

Fixes what would have shipped as noise. The warning fired whenever a fetch needed a
credential, which in a private repository adopting from a private repository is
every time and legitimate. add now resolves origin and makes one anonymous request
to learn whether this repository is public, so the warning fires when the source is
private and the destination is not. The limit is stated in the message rather than
implied away: the signal tells public from not-public and nothing finer, so two
repositories private to different people is the case that genuinely widens access
and the one this cannot see. No origin means cannot tell, which warns — cannot tell
must never read as not public.

Notes when an adopted document did not come from .loom/published/, without
refusing. What is not exported is not hidden, but a lock against it records a
dependency on something that was never a contract.

And records the framing that settles all of this: the tool is a mast, not a lock.
It grants no access, everything it does is possible with copy and paste, and the
locks mean nothing outside the tool and the discipline of the agreement — so it
makes the wrong thing deliberate rather than impossible. What it adds over a paste
is not restriction but provenance.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018UTxuSizozEA8yDitPuris
2026-09-08 08:47:12 -04:00
jeffryandClaude Opus 5 73b3a97dce contexts, and adopting by name: loomctl external add loom/cart cart
The config becomes kubectl-shaped — named contexts with one current, each holding a
host, its flavor and a read-only token — and a bare owner/repo resolves against it.
The point is where the details live: a host's raw-file route belongs to the host so
it sits in the context, the published directory belongs to the convention so it
sits in the code, and what is left is which repository and which document, which is
the only part a person knows.

Measured rather than assumed, because the three hosts differ. Gitea redirects its
short raw form to the resolved branch, so the lock records a branch without anybody
naming one. GitHub and GitLab accept HEAD and do not redirect, which would put a
moving ref in the lock — the hazard already recorded and nearly built anyway — so
those resolve the default branch with git ls-remote --symref first, one round trip
and no clone.

That supersedes the claim that list is the only command needing git, which is now
wrong for two of three flavors and would otherwise read as still true.

Adds loomctl config, which says which context is current and where the credential
came from without printing it, so that "my token is not being used" is answerable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018UTxuSizozEA8yDitPuris
2026-09-07 16:57:39 -04:00
jeffryandClaude Opus 5 104cbd050c rowan: reconcile three amendments, close three gaps, stage the answers
check found three documents moved and they were the three the gaps were filed
against, so the loop ran the other way for the first time: gaps filed, documents
amended, and the tool reported it rather than somebody saying so.

publication now carries publishing is not an act you can complete alone as its own
section, with the part we asked for — the failure is invisible from the inside,
every check available to the publisher passes — and says publication has a
visibility, which is what externals' confidentiality rule had been depending on.
recording-decisions permits compaction with the four kinds that may never be
dropped, adding that a compaction is itself a decision and gets an entry. cart is
v2 and says converting destroys, so extract first, naming the write-ahead log.

Both closed gap files are deleted at reconciliation rather than at repair, since
each stayed true of the copy held until check reported 200. cart.gaps.md keeps four
of five and gains a reframing of the third-file case: ours appeared because a round
was converted while somebody was still writing, which the agreement already
forbids, so it may be a symptom rather than a category and the fix is upstream.

cart.usages.md gains a v2 section and a note that the cart arrived headed rowan in
a file named loom-osprey.md with no name file — the name is what tags durable
entries, so a disagreement mis-files the record silently.

Answers staged in wal.md as the round runs, which is v2's rule applied to the round
that produced it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018UTxuSizozEA8yDitPuris
2026-09-07 16:49:00 -04:00
jeffryandClaude Opus 5 49c7297d08 the README had drifted into being wrong; make it short and true
It still said Not built, listed pull and reachable — neither of which exists — and
claimed everything was decided in one round. Written at a conversion and never
touched again.

Deliberately does not enumerate the commands, because a prose command list is
exactly what went stale. It points at --help, which is the documentation surface we
already decided on and which cannot drift from the binary.

Worth noticing rather than logging: check catches documents that drift from their
publisher, and nothing catches ours drifting from our own code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018UTxuSizozEA8yDitPuris
2026-09-07 15:46:25 -04:00
26 changed files with 1600 additions and 225 deletions
Vendored
BIN
View File
Binary file not shown.
+378 -74
View File
@@ -7,21 +7,6 @@ by saying so.*
---
## 2026-09-07 — what this tool is `osprey`
**Decided:** *`loom-cli` is a fetcher and a comparator.* **It fetches a document
you depend on and tells you whether the publisher's copy has moved.** *It reports
and never repairs.*
**Believed to advance:** *every act in the loom model is a file in somebody's
repository — publishing is writing one, adopting is fetching a URL, homing a gap
is an `mv`.* **Almost nothing needs a program**, so the program should be the
part that cannot be done by hand: an HTTP request, repeated.
**Belief that could be shown wrong:** *that reporting is enough.* **If every
report is followed by the same manual act, we have moved the work rather than
removed it**, and the missing command will be obvious.
## 2026-09-07 — the design lives here and not in a spec `osprey`
**Decided:** *this log is the artifact of `osprey`.* **No specification document
@@ -72,40 +57,6 @@ somebody's published surface or from a file they may rename at will.
location.* **If anybody writes code that parses it back into a URL, the path
should stop looking like one.**
## 2026-09-07 — the lock, and what it holds `osprey`
**Decided:** *one record per adopted document in `.loom/externals/.locks`: the
**resolved** origin URL, and the `ETag` it came with.*
**Believed to advance:** *the specimen said there would be no lock file, on the
strength of the `ETag`-as-blob-hash claim.* **That claim failed twice**, so the
file exists and is named for what it is. *It was briefly called `.etags`, from
when we believed there would be one field.*
**Resolved, and not the short form.** *Measured: gitea `303`s
`/loom/externals/raw/externals.md` to `/raw/branch/main/…`* — **so a lock holding a
short URL is locked to whatever the default branch is at the time you ask**, and a
branch rename reports as a change in the document.
**Belief that could be shown wrong:** *that one file is cheap.* **Two fetches in
one round conflict inside it, and the conflict is in a file no human can resolve
by reading.** *If that bites, it becomes one record per document and the tree is
mirrored twice.*
## 2026-09-07 — unlocked is a state `osprey`
**Decided:** *a document with no lock is reported as unlocked.* **`check` never
adopts whatever the remote is currently serving as the lock.**
**Believed to advance:** *adopting it would assert the local copy is the one being
served, which is the thing you were about to check.* **It is the hash assumption
in a different coat, and it fails silently in the one case that matters — a copy
somebody edited.**
**Belief that could be shown wrong:** *that anybody will run `pull` again to fix
it.* **If unlocked documents simply accumulate, the report is noise and something
has to lock them.**
## 2026-09-07 — `404` is unresolved, and that is no longer provisional `osprey`
**Decided:** *`check` reports a `404` as unresolvable — the document was withdrawn
@@ -450,16 +401,6 @@ unknown.**
next token*, **and because if a read scope is not enough, that is the only argument
for keeping ssh.**
## 2026-09-07 — `--help` is the documentation surface `marmalade`
**Decided:** *`--help` is what a person or an agent actually runs when handed an
unfamiliar CLI, so it is the surface that has to be good* — **say what a command
does and what it will not do.**
**A `.loom/published/` for `loomctl` is deferred, not declined.** *The distinction
matters because `init` was declined.* **It earns publication when somebody depends
on this tool's behaviour rather than reading its help.**
## 2026-09-07 — decided by fallback: where the credential lives `marmalade`
**Nobody chose this; the fallback became the decision and that is recorded so it
@@ -789,21 +730,6 @@ designed to be ephemeral is the first step in it not being ephemeral.**
unit.* **If two presences ever work from different machines, the cart has nowhere
to live**, *and everything above stops being true at once.*
## 2026-09-07 — conversion now destroys, so the log is the only record `quince`
**Under `cart/v1` the cart is untracked, so converting a round deletes its dailies
outright.** *Every previous conversion left them in git.*
**So an entry that was not written before the `rm` is gone**, *and the write-ahead
log is the mechanism* — **stage as you go, because at conversion the cart is the
only copy and you are about to delete it.**
**And it sharpens a conflict recorded in `cart.usages.md`:** *the annotation
protocol here says commit before dissolving because git is the only archive.*
**With an ignored cart there is no archive, so dissolving a notes file destroys
the annotations outright.** *Both documents are loom's; this is where an adopter
can see the collision.*
## 2026-09-07 — declined for now: reference-only adoption `quince`
**`externals` offers two ways out of the confidentiality rule.** *We implement
@@ -837,3 +763,381 @@ hours.**
*The warning now names both exits, including the one we have not built. **Telling
somebody a rule and not the way out of it is how a guardrail becomes something
people route around.***
## 2026-09-07 — measured: only gitea resolves its own short raw URL `rowan`
**A lock must record a resolved URL** — *a short form follows whatever the default
branch is at the time you ask, so a branch rename reports as a change in the
document.* **Adopting by name means building that URL, and the three hosts do not
behave alike:**
```
gitea /{owner}/{repo}/raw/{path} 303 -> /raw/branch/main/{path} resolved
github raw.githubusercontent.com/{o}/{r}/HEAD/{path} 200, no redirect NOT resolved
gitlab /{owner}/{repo}/-/raw/HEAD/{path} 200, no redirect NOT resolved
```
**`HEAD` works on all three and resolves on none.** *It would put a moving ref in
the lock, which is the hazard we recorded and then nearly built.*
**So gitea's redirect does the work, and the other two need the branch resolved
first** — *`git ls-remote --symref <url> HEAD`, which is one round trip and no
clone.*
**Belief that could be shown wrong:** *that gitea keeps redirecting.* **The short
form is a convenience, not a documented interface**, *and if it stops we resolve
the branch the same way as everybody else.*
## 2026-09-07 — supersedes "git is needed for `list` only" `rowan`
**Adopting by name against github or gitlab needs `git ls-remote` to resolve the
default branch.** *Against gitea it does not, because the redirect answers the
same question.*
**So the earlier claim — `list` is the only command that needs `git`, which makes
`list` the seam — is now wrong for two of three flavors.** *The seam is unchanged
in practice here, where everything is gitea, and the entry would otherwise read as
still true.*
*Recorded because the code changed in a way that quietly moved a boundary somebody
might rely on.*
## 2026-09-08 — measured: the private path works, and nothing is unexercised now `rowan`
**Supersedes the entry that recorded the git-over-HTTPS assumption as unexercised.
It has been run.** *A read-scoped token, issued for one session and destroyed
after.*
```
git clone --filter=blob:none --depth=1 over HTTPS + token, private repo OK, 124KB
loomctl external list jeffry/homelab-impl OK, authenticated
short raw URL + 303, authenticated 404 anon -> 200, resolved branch
If-None-Match across the redirect, authenticated 304
```
**So a private repository can adopt from another private one**, *by name and by
URL, with `check` working off the lock afterwards.* **Every path in the tool has
now been run at least once.**
*Incidentally confirmed: `homelab-impl` publishes nothing and adopts six documents
from `homelab-cluster`, each with a `.usages.md`.* **Consuming inward, which is
the arrangement that made the confidentiality hazard impossible in that shape.**
## 2026-09-08 — the tool is a mast, not a lock `rowan`
**Recorded because it decides a question that keeps coming back, and nothing in
the code says it.**
> **`loomctl` grants no access.** *It reads what your credentials already let you
> read.* **Anything it does, a person could do with copy and paste** — *and the
> locks mean nothing outside the tool and the discipline of the agreement.*
**So it warns and does not refuse.** *The point is not to make the wrong thing
impossible; it is to make it deliberate* — **the same move as annotating a file
you could simply edit.**
**Which is also why `add <full-url>` stays**, *even though it can reach outside a
publisher's `.loom/published/` and is, in that form, a small general-purpose file
fetcher.* **A general-purpose tool is not the aim**, *so the escape hatch is kept
and made to say what it is, rather than removed.*
**What the tool adds over copy and paste is not restriction. It is the record**
*origin, resolved branch, `ETag`.* **A pasted document has no provenance**, *so
nobody can later ask where it came from or whether we were allowed to have it* —
**not because the question is hard, but because the evidence is gone.**
## 2026-09-08 — the confidentiality check is coarse, deliberately `rowan`
**`add` now asks whether *this* repository is readable anonymously**, *by resolving
`origin` and making one unauthenticated request.* **So the warning fires when the
source is private and the destination is public, rather than on every adoption a
private repository performs** — *which was the previous behaviour and would have
been noise in exactly the workflow that is legitimate.*
**Belief that could be shown wrong, and it is a known blind spot rather than a
guess:** *the signal distinguishes public from not-public and nothing finer.*
**Two repositories private to different people is the case where private-to-private
genuinely widens access, and this check cannot see it** — *so the tool says so, in
the message, instead of implying a verdict it has not earned.*
**Failing open is deliberate:** *no origin, or an unparseable one, reports "I
cannot tell" and warns.* **"Cannot tell" must never read as "not public."**
## 2026-09-08 — three gaps closed, and the workaround they justified is now the rule `rowan`
**`publication`, `recording-decisions` and `cart` were amended in answer to gaps
filed from here.** *Closed at reconciliation rather than at repair: each stayed
true of the copy we held until `check` reported `200`.* **The gap files for the
first two are deleted; `cart.gaps.md` keeps four of five.**
**What survives is what they justified, and in every case it is the same shape —
something we invented locally became specified behaviour, unchanged in practice
and entirely changed in standing:**
- **`wal.md`.** *We built it before `cart/v2` named it, and only because it was
suggested to us.* **`v2` cites that:** *a practice that works only when somebody
is standing there to suggest it is not a practice.*
- **"A fetch you did not perform."** *Ours was a declined command and an insight
kept without an implementation; it is now the test in `publication`.*
- **The compaction rule**, *including the four kinds that may never be dropped,*
**plus one addition we did not think of: a compaction is itself a decision and
gets an entry.**
## 2026-09-08 — declined: fixing `annotating` `rowan`
**Four real gaps stand unfixed, deliberately.** *It is the most-depended-on
document here and a revision costs every adopter an afternoon.*
**Two of the four are not gaps in the agreement at all.** *The rule exists — the
source hash that detects a violated freeze, and **dissolve** as the definition of
what responding is — **but it lives in a local editor protocol and not in the
agreement**.* **That needs a decision about where a rule lives, which is not an
amendment and is not ours.**
**Local answers keep running:** *verify the source hash and the anchors before
responding, and say so if they disagree.*
## 2026-09-08 — a casting file goes stale on amendments that add no role `rowan`
**Twice in one day.** *`cart/v1` and `cart/v2` each invalidated nothing we had
cast and each left `cart.usages.md` incomplete.*
> **`check` reports that a document moved and cannot say whether your casting
> still covers it.** *The mechanism is that it prints the `.usages.md` beside a
> staged polad, which it did, both times.* **It points; it cannot make anybody
> read.**
**Belief that could be shown wrong:** *that pointing is enough.* **Nothing checks
that a facet was updated, or that it was opened.**
*Related, and the reason this entry can be trusted: this round arrived headed
`rowan` in a file named for a round that had already converted, with no `name`
file.* **The name is what tags durable entries**, *so a mismatch mis-files the
record permanently.* **The heading won; it is recorded in `cart.usages.md`.**
## 2026-09-08 — declined: a round on the third-file question `rowan`
**Not opened, and not sent upstream as a gap either.** *Left open in
`cart.gaps.md` where it was filed.*
**Because it may not be a category.** *Ours appeared because a round was converted
while somebody was still writing, which the agreement already forbids.* **A third
file that only ever arrives as a conversion-timing failure has its fix upstream**,
*and the choice between moving it and rejecting it never has to be made.*
> **Before writing the rule, count how many arrive any other way.**
*One instance is not a sample* — **which is the reason `cart/v2` gave for leaving
it alone, applied one step further.**
## 2026-09-08 — `orient`, and declined: a second orientation file `sorrel`
**Decided:** *one generated file, `.loom/orientation.md`, for whoever arrives
next.* **It restates no rule** — *every rule it might repeat is owned by a
document already in the working tree, and a copy of a rule is a copy that goes
stale.*
**Declined: splitting it into an externals half and a publishing half.** *The
splitting rule is **generate what varies, adopt what does not*** — **and the
publishing half varies not at all and is already adopted**, *so `publication.md`
appears in the index like any other adopted document, in exactly the repositories
that adopted it.* **A second command would emit a file whose whole content is a
pointer to a file already in the tree**, *and would revive the `published` noun
that went with `published check`.*
**Declined: `.loom/externals/README.md` as the path**, *which was measured rather
than argued:*
```
$ loomctl external check
README.md unlocked no origin recorded — ...
```
**`check` walks that directory for adopted documents, so a generated file there
reports as unlocked in every repository using the feature, forever.** *And the
deeper reason is the invariant: everything under `.loom/externals/` is somebody
else's document, which is what makes "do not edit these" a rule you can state in
four words.*
**The `.usages.md` is pointed at, not summarised.** *What depends on a document is
free prose, and anything that extracts a claim from prose will eventually extract
it wrong.*
**Belief that could be shown wrong:** *that a fixed path is worth more than a
configured one.* **It only pays if it is the same everywhere**, *which is not ours
to decide and is with `bedrock`.*
## 2026-09-08 — a probe is not a fetch, and confusing them clears alarms `sorrel`
**Recorded because the bug was one line and the trap is permanent.**
*Visibility was computed by one function used in two places.* **At `add` time,
"no credential is configured" means the fetch that just succeeded was anonymous,
so the source is public** — *sound.* **In the audit, nothing is being fetched:
"no credential configured" says nothing about whether a stranger could read the
URL**, *and returning `public` there would have **silently cleared real
alarms**.*
> **The worst failure available to a check is to report that it found nothing.**
**Probing is now its own function that always asks with no credential at all**,
*because what matters is what a stranger can read and not what we can.* **Found by
testing the alarm path rather than after shipping it.**
## 2026-09-08 — `orient`, not `prompt`, and why that was mine to notice `sorrel`
**Renamed before it shipped.** *`prompt` reads as "produce something to feed a
model", and the file is a table of contents a person can read.*
**The correction was right and the assumption behind it was mine:** *`prompt` was
not being used in the narrow sense, and I took it at the narrowest reading
available.* **Second time in two days** — *the first was reading `others` as
`everyone` in `publication.md`, which cost a command that was built, validated
three times, and then declined.*
*Recorded as a working note rather than a decision: **when a word could be read
narrowly or broadly, this repository has evidence that I pick narrow.***
## 2026-09-08 — published: what the tool guarantees `tansy`
**`.loom/published/guarantees.md`**, *and step 4 of `publication` was run: `200`
anonymously, off no credential of ours.*
**The earlier decision did not reverse; its condition fired.** *`.loom/published/`
was recorded as deferred rather than declined, on the condition that somebody
depends on the tool's behaviour rather than reading its help.* **A docs site whose
build calls `add`, `check` and `apply` is that consumer.**
*The delta is not the command list — that is a copy of what the code owns.*
**`--help` says what the tool does; a published page says what will keep being
true**, *and a reader cannot learn from a binary which of its behaviours survives
the next version.*
**The page includes what is not promised**, *which is the half a reimplementation
needs most:* **the command surface, the orientation file's format across
versions, the precision of the visibility signal, and that anything is checked
when nobody is looking.**
## 2026-09-08 — we do not promise the surface; we promise the change lands `tansy`
**A consumer's README tells a person to type `loomctl external add loom/<repo>
<doc>.md`.** *Nothing parses it — which is exactly why no check would catch a
rename.* **The thing that moved would not be a document anybody adopted.**
**Promising the surface would freeze the CLI. So the promise is narrower and
runs:**
> **We do not promise the surface holds. We promise a change to it lands on the
> page you already lock.**
*`guarantees.md` carries an append-only `Surface changes` section*, **so a rename
reports `200` to every consumer the day it happens.**
**Belief that could be shown wrong:** *that anybody remembers to write in it.*
**Nothing enforces the section***the same weakness as a facet nobody updates.*
## 2026-09-08 — two ETags can be compared and never ordered `tansy`
**Found by accident, and it is a consequence of a rule we published this
morning.**
*The docs site stamped a hand-written guide with the `ETag` it was read against,
and its banner said `written against an older guarantees`.* **It was backwards:
the guide was ahead of the site's copy, not behind.**
**And it cannot be fixed by trying harder.** *An `ETag` is opaque — that is the
point of it, and we guarantee we never interpret one.* **So a consumer can know
that two versions disagree and can never know which is behind.**
*The banner now claims no direction.* **Anybody building freshness on this
inherits the same limit**, *and it belongs in the log because it follows from the
guarantee rather than from anybody's code.*
*Consequence nobody predicted: reconciling cleared the banner with no
acknowledgement, which is correct — an acknowledgement is for a guide that is
behind, and this one was ahead.*
## 2026-09-08 — compacted, and it barely compressed `tansy`
**A compaction is itself a decision, so this is the entry `recording-decisions`
asks for.** *Six entries dropped, fifty kept. The uncompacted log is in git
history, which is where a reader who needs it goes.*
**Dropped, each because a competent reader can now recover it from the artifact,
and here is which artifact:**
- *`what this tool is`* — **`.loom/published/guarantees.md` and `README.md`**
- *`the lock, and what it holds`* — **`guarantees.md` documents the format**
- *`unlocked is a state`* — **`guarantees.md`, and `add` refuses in code**
- *`--help is the documentation surface`* — **`README.md` says so and points there**
- *`the lock records visibility, and the word it will not use`* — **`guarantees.md`
carries the field and the refusal of the word `private`**
- *`conversion now destroys, so the log is the only record`* — **`cart/v2`, adopted
here, and `cart.usages.md` names `wal.md` as the mechanism**
> **Every one of them became droppable by being published.** *Nothing was
> summarised away; the content moved into a document somebody else can lock, which
> is a stronger place for it than a log only we read.*
## What did not compress, and why that is the finding
**Fifty entries survived, and the reason is the rule rather than sentiment.**
*Almost all of them are one of the four kinds that may never be dropped:*
**Declines** — *`init`, `published check`, a separate `adopt` verb, a plugin
boundary, an audience declaration, reference-only adoption, resolving `404` over
ssh, a second orientation file, worktrees, a spec, fixing `annotating`.* **Not one
is recoverable from the code, because the code is what they are absent from.**
**Measurements** — *the `ETag` is a blob hash on gitea and not on GitHub; three
hosts and one partial clone; only gitea resolves its own short raw URL; a refused
write names the token's scopes; a private repository over HTTPS with a read
token.* **Facts about other people's systems, which no artifact here holds.**
**Supersessions** — *publication is not publicness; a cart is not committed; `add`
does not replace; `git` is not needed for `list` only; the private path is
exercised.* **Each is a change of mind, which is most of what a reader wants.**
**Beliefs shown wrong** — *the specimen's hash-as-lock; `others` read as
`everyone`; a probe treated as a fetch; a banner that claimed a direction two
opaque strings cannot support.*
> **The log is long because the work was exploratory, not because it is padded.**
> *A project that mostly built things would compact to a page. This one mostly
> found out it was wrong about things, and that is the part the code cannot
> hold.*
**Belief that could be shown wrong:** *that fifty is readable.* **It is today.**
*The next remedy is not another compaction — it is the guide `bedrock` needed,
and it is not needed yet.*
## 2026-09-08 — a `404` at adoption has a third reading, and it is resolvable
**`externals` names two readings of a `404` — withdrawn, or access lost — and
they are the two a **locked** document can have.** *Adoption by name has a third:
**a document that was never there under that name**.*
*Found in use: `loomctl external add jeffry/homelab-cluster database` reported the
ambiguity for what was a missing `s`.*
**The tool was reporting an ambiguity it had the means to resolve.** *For the
by-name form it knows the repository, so on a `404` it now lists the published
surface:*
```
loomctl: jeffry/homelab-cluster publishes no "database.md". It publishes:
databases.md gitea.md private-access.md ...
```
**And it only claims that when the listing succeeds.** *If listing fails too, the
repository itself is unreachable and the original ambiguity is the honest
answer* — **the same discipline as reporting `public` and `not-public` rather than
`private`: say the thing you verified.**
**Not a gap against `externals`.** *The third reading cannot occur where that
document is speaking, which is `check` against a lock.* **It appears only at
adoption, which is ours.**
**Belief that could be shown wrong:** *that an extra round trip on a failure is
free.* **It is one partial clone on a path nobody takes twice**, *and if adoption
against large repositories becomes common the listing should be bounded.*
+13 -11
View File
@@ -1,12 +1,14 @@
# loomctl locks — one record per adopted document.
# path<TAB>url<TAB>etag The url is resolved: a short form would follow
# whatever the default branch is at the time you ask.
git.hypertheory-labs.dev/jeffry/homelab-cluster/gitea.md https://git.hypertheory-labs.dev/jeffry/homelab-cluster/raw/branch/main/.loom/published/gitea.md "530c5bef62bbd325956ed170bb2decf37975e4b9"
git.hypertheory-labs.dev/loom/annotating/annotating.md https://git.hypertheory-labs.dev/loom/annotating/raw/branch/main/.loom/published/annotating.md "9b1f7e6ca92f2339b2d433686c27845362944bdb"
git.hypertheory-labs.dev/loom/bedrock/loom-directory.md https://git.hypertheory-labs.dev/loom/bedrock/raw/branch/main/.loom/published/loom-directory.md "ee0f49cb900c0812678061971194325d9cba366a"
git.hypertheory-labs.dev/loom/bedrock/publication.md https://git.hypertheory-labs.dev/loom/bedrock/raw/branch/main/.loom/published/publication.md "eb0cb63629a36b056f215dfbe24567d1918cec38"
git.hypertheory-labs.dev/loom/bedrock/recording-decisions.md https://git.hypertheory-labs.dev/loom/bedrock/raw/branch/main/.loom/published/recording-decisions.md "970d4b4da76aac99c9b1f1b6580ec20daa42e329"
git.hypertheory-labs.dev/loom/bedrock/sibling-facets.md https://git.hypertheory-labs.dev/loom/bedrock/raw/branch/main/.loom/published/sibling-facets.md "a46446a34ccb8bfc533d3cce19f4c88548c4fa04"
git.hypertheory-labs.dev/loom/bedrock/starting.md https://git.hypertheory-labs.dev/loom/bedrock/raw/branch/main/.loom/published/starting.md "7d997a30248a88c90b23f2f9453d7cfceb03848e"
git.hypertheory-labs.dev/loom/cart/cart.md https://git.hypertheory-labs.dev/loom/cart/raw/branch/main/.loom/published/cart.md "49fc852bdd280293f0f5e0050034e3b89ff7255e"
git.hypertheory-labs.dev/loom/externals/externals.md https://git.hypertheory-labs.dev/loom/externals/raw/branch/main/.loom/published/externals.md "a7586eb52caf275d9bcedbbd8042c43e5aaad0b9"
# path<TAB>url<TAB>etag[<TAB>visibility]
# The url is resolved: a short form would follow whatever the default branch
# is at the time you ask. visibility is what the source could be read as when
# it was fetched, because that is checked once and the copy is durable.
git.hypertheory-labs.dev/jeffry/homelab-cluster/gitea.md https://git.hypertheory-labs.dev/jeffry/homelab-cluster/raw/branch/main/.loom/published/gitea.md "530c5bef62bbd325956ed170bb2decf37975e4b9" public
git.hypertheory-labs.dev/loom/annotating/annotating.md https://git.hypertheory-labs.dev/loom/annotating/raw/branch/main/.loom/published/annotating.md "9b1f7e6ca92f2339b2d433686c27845362944bdb" public
git.hypertheory-labs.dev/loom/bedrock/loom-directory.md https://git.hypertheory-labs.dev/loom/bedrock/raw/branch/main/.loom/published/loom-directory.md "ee0f49cb900c0812678061971194325d9cba366a" public
git.hypertheory-labs.dev/loom/bedrock/publication.md https://git.hypertheory-labs.dev/loom/bedrock/raw/branch/main/.loom/published/publication.md "c0d9231aa8eed3041c60b72da5735585457fbc90" public
git.hypertheory-labs.dev/loom/bedrock/recording-decisions.md https://git.hypertheory-labs.dev/loom/bedrock/raw/branch/main/.loom/published/recording-decisions.md "d190eeae7929b9a99ecf09211d94e7e5b32635bd" public
git.hypertheory-labs.dev/loom/bedrock/sibling-facets.md https://git.hypertheory-labs.dev/loom/bedrock/raw/branch/main/.loom/published/sibling-facets.md "a46446a34ccb8bfc533d3cce19f4c88548c4fa04" public
git.hypertheory-labs.dev/loom/bedrock/starting.md https://git.hypertheory-labs.dev/loom/bedrock/raw/branch/main/.loom/published/starting.md "b9eefba0f4668a496ccfc6a1377277f0721456d2" public
git.hypertheory-labs.dev/loom/cart/cart.md https://git.hypertheory-labs.dev/loom/cart/raw/branch/main/.loom/published/cart.md "15331f1a9cc81bf61a44830cfbb7c274f4c2b119" public
git.hypertheory-labs.dev/loom/externals/externals.md https://git.hypertheory-labs.dev/loom/externals/raw/branch/main/.loom/published/externals.md "50673ccffc14d57150ac0a9027b0712d9dcf940d"
@@ -1,29 +0,0 @@
# Gaps — `publication`
## Whether publishing implies that the audience can read it
**The document says publishing is a change of kind and that `.loom/published/` is
what a repository has handed over for others to depend on.** *It does not say that
the handing over must succeed.*
**We built a command on the assumption that it meant *anyone* could fetch it, and
that was wrong** — *others is not everyone, and a repository may publish to a
private audience deliberately.* **But the opposite is not addressed either:
nothing here says that a document in `published/` which nobody in its intended
audience can fetch has not been published.**
*The sentence we needed is in a sibling and not here:* **publishing is not an act
you can complete alone.** *It appears as an aside about a tool, not as a property
of publication.*
*Local answer: we declined to build the check, on the belief that a registry with
named consumers answers it better than a probe can. **The convention still does not
say whether an unfetchable publication is a publication.***
## Where confidentiality lives
**`externals` now says confidentiality does not travel with the copy.** *That rule
is about adopting, and it exists because of a property of publishing* — **what a
publisher may safely put in `published/` depends on who can read the repository it
is in**, *and this document, which is where publishing is defined, does not
mention visibility at all.*
@@ -15,6 +15,43 @@ should be unmissable.*
**`ls .loom/published/` is *what have we committed to?*** — and it is the question
to ask before changing anything.
## Publishing is not an act you can complete alone
**A file in `.loom/published/` that its intended readers cannot fetch is not
published.** *The move is the act, but the act is not finished until somebody who
is not you can perform the read.*
> **The failure is invisible from the inside.** *Every check available to the
> publisher passes — the file is there, the commit landed, the path is right.*
> **Only a reader can detect it, and a reader who cannot fetch it does not know
> the document exists to ask about.**
*The commonest form is a document that answers "why can you not see this?" and is
published **inside** the thing that cannot be seen.*
**So publication has a visibility, and it is a property of the act rather than of
the file.** *"Others" is not "everyone".* **Naming who the readers are is part of
publishing**, because a repository that depends on this one has to know whether it
is inside that set — *and because a reader who copies a document out inherits the
copy without inheriting the visibility it was published under.*
**The test is a fetch you did not perform.**
## Doing it
1. **Decide it is a contract and not a detail.** *This is the whole decision and
the rest is mechanics.*
2. **Move the file into `.loom/published/`.** *One commit, so the diff carries it.*
3. **Say who the readers are***if it is not obvious from where it landed.*
4. **Fetch it.** *From somewhere that is not your machine and not your account.*
5. **Record the decision**, if you keep a log.
**Step 4 is the one that gets skipped and the only one that can fail.** *Steps 1
to 3 are things you do; step 4 is a fact about the world that you find out.*
*Then say what it depends on. A published document that quietly requires a private
one has moved the problem rather than solved it.*
## What is published is a document
**Prose, in whatever form its readers need.** *If a repository organises its
@@ -1,28 +0,0 @@
# Gaps — `recording-decisions`
## Whether a log may ever be compacted, and what compaction may not touch
**The document says entries are appended, newest last, and never revised** — *"a
revised record cannot show that anybody changed their mind, which is most of what
a reader wants from it."*
**It also acknowledges no upper bound.** *Ours reached about thirty entries in a
day across three rounds, and `bedrock`'s own README exists because its log needed
a guide.* **At some size the log stops being readable, and the only remedies are
revision, which this forbids, or a guide, which is a second document that can
drift.**
**The gap is not "may we compact".** *It is that **"never revised" and "somebody
must be able to read it" both hold and eventually conflict**, and the document
does not say which gives.*
*Local answer, provisional: **compaction is allowed and git history is where the
uncompacted log lives.*** *An entry may be dropped when a competent reader could
recover it by reading the code.* **An entry may never be dropped when it records a
decline, a measurement, a belief that was shown wrong, or one entry superseding
another** — *because those are precisely the record of somebody changing their
mind, and dropping them is the failure this document names.*
> **Which means compaction is safe in exactly the cases where the entry was
> redundant with the artifact, and unsafe in exactly the cases the log exists
> for.**
@@ -24,6 +24,29 @@ reads.*
you already rejected — because the rejection lived somewhere that did not
survive.*
## Compaction, and the entries it may never touch
**"Never revised" and "somebody must be able to read it" both hold, and far
enough out they conflict** — *a log nobody can get through has stopped being a
record.*
**Compaction is allowed. Dropping an entry is not the same as revising one**, and
the uncompacted log is in git history, which is where a reader who needs it goes.
> **An entry may be dropped when a competent reader could recover it by reading
> the artifact.**
>
> **An entry may never be dropped when it records a decline, a measurement, a
> belief that was shown wrong, or one entry superseding another.**
*Which means **compaction is safe in exactly the cases where the entry was
redundant with the artifact, and unsafe in exactly the cases the log exists
for.*** **The four protected kinds are the ones with no other home:** *the code
shows what was built and can never show what was refused, what was measured, what
turned out false, or that somebody changed their mind.*
**A compaction is itself a decision, and gets an entry.**
## Where it lives
`.loom/event-log.md`. **A stream, so a file.**
@@ -60,13 +60,16 @@ what they came for.*
## Look at one instead of reading this
**There is a worked example — six documents, one gap, no decomposition — and it
is private.**
**[`jeffry/homelab-cluster`](https://git.hypertheory-labs.dev/jeffry/homelab-cluster)** —
*six documents, one gap, no decomposition.* **It describes what a home cluster
offers to somebody deciding whether to put something on it**, and a reader who
has never heard of any of this still gets what they came for.
*It describes a cluster in enough detail to be a target list, so it is not
published, and **this page will not link you to something you cannot fetch.***
**If you have access, ask for it by name; if you do not, the two questions at the
top are the whole of it.**
**It was private, and being unable to link to it is what made the split obvious.**
*The operational tree is a version inventory and a target list; the pages telling
somebody what to decide are neither.* **So the manifests moved to a private
sibling and the documents stayed** — *and the documents got better for being
written to a stranger, which was not the reason for doing it.*
> **A public page naming a private thing as its canonical answer is worse than no
> example**, and this page did exactly that until somebody measured it.
@@ -44,19 +44,17 @@ been written.*
presence's daily* — **but "move it" and "reject it" are different acts with
different costs**, *and picking one was ours to invent.*
## What must be extracted before converting, now that conversion destroys
**Added after the fact, and it may dissolve the gap rather than answer it:** *ours
appeared because the round was converted while somebody was still writing.* **The
agreement already has a rule against that** — *do not convert while somebody is
mid-thought* — **so this third file was a symptom and not a category.**
**`v1` makes the cart untracked. So converting deletes the dailies outright**,
*where every earlier conversion left them in git.*
> **Before writing a rule for third files, count how many arrive any other way.**
> *If they are all conversion-timing failures, the fix is upstream of here and the
> two acts never have to be chosen between.*
**The write-ahead log is named in `v1` but not required**, *and nothing says that
at conversion the cart is the only copy.* **Two rules now both push toward loss**
*act as if the shelf is discarded daily, and the cart is not committed* — **and
neither says what has to be written down first.**
*Local answer: a `wal.md` in the cart, staged as we go, so converting is a move
rather than a rewrite. **We would not have thought of it if it had not been
suggested.***
*One instance is not a sample. **We would rather leave this open than freeze a
rule out of a single case**, which is the same reason `v2` did not answer it.*
## Whether an unanswered `Affirm` and a fallback are the same silence
+29 -1
View File
@@ -1,6 +1,6 @@
# Agreement — the cart
**v1.** Depends on `annotating/v0`.
**v2.** Depends on `annotating/v0`.
**How two parties work out what something means before one of them changes it.**
@@ -92,6 +92,34 @@ words.** That costs precision and buys something better:
> **Pinning a defect in place lets you be exact without having understood.
> Restating surfaces the misunderstanding that pinning hides.**
## Converting destroys, so extract first
**The cart is not committed. So conversion does not archive it — it deletes it.**
*Every earlier version of this agreement was wrong about that by accident: a
committed cart left its dailies in history, and "gone" was only ever true of the
tree.*
> **At conversion the cart is the only copy of everything in it.** *The dailies,
> the annotations, the answers to every prompt, and the write-ahead log.*
**So before converting, extract:**
- **every answer to a `Question`, `Select` or `Affirm`** — *an answer lands in a
notes file, a notes file dies at dissolve, and an untracked cart has no history
to fall back on.* **A prompt answered and not extracted was never answered.**
- **every write-ahead entry that survived** — *promote or discard, and both are
acts.*
- **every fallback that quietly became the decision** — *say so in the entry, in
those words.*
**Staging as you go turns conversion into a move rather than a rewrite.** *Keep
the write-ahead log current during the round and there is nothing to reconstruct
at the end, which is when you have least appetite for it.*
*Two rules in this agreement now both push toward loss — **act as if the shelf is
discarded daily**, and **the cart is not committed.** They are both right and
together they mean **nothing in a cart is safe by default.***
## Conversion emits two things
**The artifact** — the code, the decision, the change — **and the declines.**
@@ -64,3 +64,37 @@ no archive*, **so dissolving a notes file destroys the annotations outright.**
*Both documents are loom's. **Recorded here because the conflict is visible from
inside an adopter and not from inside either document.***
---
## Cast against `v2`: extract before converting
**`v2` adds no role.** *It adds an obligation at conversion, and this file is
where an adopter looks for what this repository does about it.*
**The write-ahead log is `wal.md`, in the cart, staged during the round.** *Cast
2026-09-07. It existed here before `v2` named it, as a local answer to the gap
`v2` closed.*
> **What comes out before the `rm`:** *every answer to a `Question`, `Select` or
> `Affirm`; every surviving write-ahead entry; and every fallback that quietly
> became the decision, said in those words.* **Destination is
> `.loom/event-log.md`**, *which is the durable record cast at the top of this
> file.*
**The status of our workaround changed and the code did not.** *Staging as we went
was a thing we invented and would not have thought of unaided.* **It is now
specified behaviour** — *unchanged in practice, entirely changed in standing.*
## Still uncast, because the document does not ask
**Nothing here says what a cart's `name` file must agree with.** *This round
arrived as a daily headed `rowan`, in a file called `loom-osprey.md`, with no
`name` file at all.* **The name is what tags durable entries** — *"everything
tagged with this name was considered in a single sphere of understanding"* — **so a
disagreement between the heading and the file mis-files the record, silently and
permanently.**
*Local answer: **the daily's own heading wins**, `name` is written to match, and
the file is renamed.* **Recorded here rather than filed as a gap**, *because it may
just be somebody typing fast.*
@@ -62,6 +62,7 @@ what to decide are not.**
- **where it was fetched from** — *resolved, as above*
- **the publisher's `ETag`** — *verbatim*
- **the visibility the source could be read at, when it was fetched**
**It is committed**, because the thing it locks is committed, and *a lock that
travels separately from what it locks is the drift this is meant to prevent.*
@@ -71,6 +72,26 @@ travels separately from what it locks is the drift this is meant to prevent.*
> asserts your copy is the one being served, which is the thing you were going to
> check.
### Why visibility is in the lock and not left to the moment
**Access is checked once and the copy is durable.** *A token gets you the read;
nothing revisits it afterwards.* **So the adoption's legitimacy rests on the
relative visibility of two repositories — and that is changeable by a checkbox, a
year later, by somebody who never saw the adoption.**
> **Record `public` or `not-public`. Never `private`.** *An anonymous request can
> tell you a repository is not public. It cannot tell two repositories private to
> **different people** apart* — **which is exactly the case where private into
> private genuinely widens access.** *Record the word you can verify.*
**Checking it costs one request per run, not one per document**, *because the only
thing that must be current is **your own** visibility.* **Re-check a source only
when the alarm would fire** — *you are public, it was recorded not-public* — *and
if it has since gone public, update the lock and say nothing.*
*A stored visibility decays in both directions, which is why the alarm re-checks
rather than trusting the record.*
## Freshness is a conditional request
**Locked on the publisher's `ETag`, verbatim — never a hash you compute.** *A
+69
View File
@@ -0,0 +1,69 @@
<!-- Generated by `loomctl orient`. Safe to overwrite; nobody typed it.
Regenerate after adopting a document or reconciling a change. -->
# What this repository depends on
Copies of other people's documents are kept under `.loom/externals/`, at a path
that says where each came from. **They are copies: do not edit them.** Anything
you want to say about one goes in a file *beside* it, never into it.
Three moves, and each has a document that owns the rule:
- **A copy is wrong, or you needed something it does not say** — write it in
`<name>.gaps.md` beside the copy.
- **What of ours depends on a copy** — write it in `<name>.usages.md` beside it.
- **A source changed** — `loomctl external check` says so and stages the new copy;
`loomctl external apply` takes it. Neither edits anything on its own.
## `git.hypertheory-labs.dev/jeffry/homelab-cluster/gitea.md`
- source: https://git.hypertheory-labs.dev/jeffry/homelab-cluster/raw/branch/main/.loom/published/gitea.md
- what of ours depends on it: `.loom/externals/git.hypertheory-labs.dev/jeffry/homelab-cluster/gitea.usages.md`
## `git.hypertheory-labs.dev/loom/annotating/annotating.md`
- source: https://git.hypertheory-labs.dev/loom/annotating/raw/branch/main/.loom/published/annotating.md
- what of ours depends on it: **nothing recorded** — no `.usages.md`
- what we expected and did not find: `.loom/externals/git.hypertheory-labs.dev/loom/annotating/annotating.gaps.md`
## `git.hypertheory-labs.dev/loom/bedrock/loom-directory.md`
- source: https://git.hypertheory-labs.dev/loom/bedrock/raw/branch/main/.loom/published/loom-directory.md
- what of ours depends on it: **nothing recorded** — no `.usages.md`
## `git.hypertheory-labs.dev/loom/bedrock/publication.md`
- source: https://git.hypertheory-labs.dev/loom/bedrock/raw/branch/main/.loom/published/publication.md
- what of ours depends on it: **nothing recorded** — no `.usages.md`
## `git.hypertheory-labs.dev/loom/bedrock/recording-decisions.md`
- source: https://git.hypertheory-labs.dev/loom/bedrock/raw/branch/main/.loom/published/recording-decisions.md
- what of ours depends on it: **nothing recorded** — no `.usages.md`
## `git.hypertheory-labs.dev/loom/bedrock/sibling-facets.md`
- source: https://git.hypertheory-labs.dev/loom/bedrock/raw/branch/main/.loom/published/sibling-facets.md
- what of ours depends on it: **nothing recorded** — no `.usages.md`
## `git.hypertheory-labs.dev/loom/bedrock/starting.md`
- source: https://git.hypertheory-labs.dev/loom/bedrock/raw/branch/main/.loom/published/starting.md
- what of ours depends on it: **nothing recorded** — no `.usages.md`
## `git.hypertheory-labs.dev/loom/cart/cart.md`
- source: https://git.hypertheory-labs.dev/loom/cart/raw/branch/main/.loom/published/cart.md
- what of ours depends on it: `.loom/externals/git.hypertheory-labs.dev/loom/cart/cart.usages.md`
- what we expected and did not find: `.loom/externals/git.hypertheory-labs.dev/loom/cart/cart.gaps.md`
## `git.hypertheory-labs.dev/loom/externals/externals.md`
- source: https://git.hypertheory-labs.dev/loom/externals/raw/branch/main/.loom/published/externals.md
- what of ours depends on it: `.loom/externals/git.hypertheory-labs.dev/loom/externals/externals.usages.md`
## This repository publishes
See `.loom/published/`. What is handed over for others to depend on is
there; the rest of the repository is not hidden, it is simply not what
anybody depends on.
+114
View File
@@ -0,0 +1,114 @@
# What `loomctl` guarantees
**Not what it does — `--help` says that, and the commands will change.** *This is
what will keep being true while they do.*
---
## It grants no access, and records where the copy came from
**`loomctl` reads what your credentials already let you read.** *Everything it
does is possible with copy and paste.* **It is a mast, not a lock** — *the point
is to make the wrong thing deliberate, not impossible.*
> **What it adds over a paste is provenance.** *A pasted document cannot answer
> "where did this come from, and were we allowed to have it" — not because the
> question is hard, but because the evidence is gone.*
## It never writes over the network
**No push, no publish, no `POST`, no token that needs write scope.** *Every byte
it writes is a file in your working tree.* **Committing and pushing are yours**,
*because the consequences of a push land on people a tool cannot experience.*
**A credential given to `loomctl` should never carry write scope**, *and if one
does, nothing here will use it.*
## Freshness is a conditional request against the publisher's `ETag`
**Stored verbatim, opaque, never a hash we compute.** *A fetch that normalises
anything breaks a local digest and reports a change that did not happen.*
*This is `externals`' rule and `loomctl` is a second holder of it. **It is stated
here so that the tool holding it is a fact somebody can find**, and so that
breaking it is visible rather than silent* — **a specimen was written in this
repository that computed a hash instead, and it was wrong within a day.**
**The URL in a lock is resolved.** *A short form follows whatever the default
branch is at the time you ask, so a branch rename would report as a change in the
document.*
## It reports; it does not repair
**Nothing is overwritten.** *A document that moved upstream is written to a
staging area as a candidate, and taking it is a separate act.* **A local copy
that differs from what the publisher serves is left alone** — *it is the only
evidence that something changed while nothing was watching.*
**`add` adopts what is absent and refuses what is already adopted.** *A document
that is present but unlocked is locked only when the bytes are identical to what
the publisher serves*, **so a lock's claim — this copy is the one being served —
is verified rather than assumed.**
## The lock file
**`.loom/externals/.locks`, one record per adopted document, tab-separated,
ordered by path.**
```
path <TAB> url <TAB> etag [ <TAB> visibility ]
```
- **`path`** — *relative to `.loom/externals/`, and **for a person to read**.* **It
does not round-trip to a URL**; *it drops the route, the branch, and the
publisher's `.loom/published/`.*
- **`url`** — *the resolved origin, branch and all.*
- **`etag`** — *the publisher's, verbatim, including its quotes.*
- **`visibility`** — *optional. What the source could be read as **when it was
fetched**: `public` or `not-public`.* **Never `private`** — *an anonymous
request tells those two apart and nothing finer.*
**Records with three fields remain valid.** *A repository does not stop working
because the tool learned something new.*
## What is not promised
**The command surface.** *Names, flags and output are `--help`'s business and may
change. Nothing should parse them.*
**The generated orientation file's format.** *It is byte-deterministic within a
version so that its diff is readable; it is not stable across versions.*
**That visibility is precise.** *The signal distinguishes public from not-public
and nothing finer, so it cannot see two repositories private to different people
— which is the case where adopting between private repositories genuinely widens
access.* **The tool says so where it reports it, rather than implying a verdict it
has not earned.**
**That anything is checked when you are not looking.** *Nothing here runs on a
schedule, and a document nobody checks is a document nobody is checking.*
## What is promised about the things that are not promised
**The command surface may change. This page will say when it does.**
*A consumer whose README tells a person to type `loomctl external add loom/<repo>
<doc>.md` depends on that command by name — **not by parsing it, which is why no
check would ever catch a rename**. The thing that moved would not be a document
they adopted.*
> **So the exclusion is narrower than "you are on your own".** *We do not promise
> the surface holds; **we promise that a change to it lands here**, on a page you
> can lock* — **and a lock on this page reports `200` the day a command is
> renamed.**
**That is the whole mechanism, and it costs one section:**
### Surface changes
*Append-only, newest last. Nothing yet — the commands have not been renamed since
this page existed.*
---
*Verified fetchable by somebody who is not us on 2026-09-08.*
+13 -12
View File
@@ -1,17 +1,18 @@
# loom-cli
**Not built.** *A small tool for the operations a person should not do by hand:
fetch a document you depend on, and find out when it changed.*
**`loomctl` fetches the documents this repository depends on, and tells you when
they change.** It reports; it never repairs.
**The design is in [`.loom/event-log.md`](.loom/event-log.md)**, *not in a spec.*
**Every entry says what was decided and the belief that could turn out false**, so
you can see which parts are load-bearing and which were guesses. *Everything
tagged `osprey` was decided in one round.*
```
go build -o loomctl .
./loomctl --help
```
**Three commands.** *`pull` adopts a document and writes its lock; `check` asks
every publisher whether their copy has moved; `reachable` asks whether somebody
who is not you can fetch what you published.* **None of them repairs anything.**
`check` only reads. Nothing here writes over the network, so a credential it is
given never needs write scope.
**`bedrock` and `externals`, under [`.loom/externals/`](.loom/externals/), are not
discardable.** *Read them as given — accommodating them is what makes this a loom
tool rather than some other thing.*
**Why it is the way it is:** [`.loom/event-log.md`](.loom/event-log.md). Every
entry says what was decided and the belief that could turn out to be false.
**What it depends on, and what we could not tell from those documents:**
[`.loom/externals/`](.loom/externals/) — the `.gaps.md` files beside each one.
+77 -18
View File
@@ -1,9 +1,12 @@
// Package config reads the per-host settings loomctl needs to talk to a git host.
//
// The config is not only a secret. It is how you talk to a host at all, which is
// why it is keyed by host rather than being a single token. It lives in the
// user's home directory and never in a repository — see .loom/event-log.md,
// "decided by fallback: where the credential lives".
// The config is not only a secret. It is how you talk to a host at all which
// is why it is a set of named contexts with one current, and why a host's URL
// shapes live here rather than in what a person types. Nobody should have to
// know that gitea serves raw files from /raw/branch/<branch>/ to adopt a
// document.
//
// It lives in the user's home directory and never in a repository.
package config
import (
@@ -16,15 +19,19 @@ import (
"strings"
)
// Host is what we know about one git host.
type Host struct {
// Token is a read-only personal access token. It must not carry write
// scope: loomctl never writes over the network.
// Context is one named way of talking to one host.
type Context struct {
Host string `json:"host"`
// Flavor selects the URL shapes: gitea, github or gitlab. Empty means gitea.
Flavor string `json:"flavor,omitempty"`
// Token is read-only. loomctl never writes over the network, so a token it
// is given should not carry write scope.
Token string `json:"token,omitempty"`
}
type Config struct {
Hosts map[string]Host `json:"hosts"`
CurrentContext string `json:"current-context"`
Contexts map[string]Context `json:"contexts"`
}
// Path is where the config lives. Never inside a repository.
@@ -39,10 +46,10 @@ func Path() string {
return filepath.Join(home, ".config", "loomctl", "config.json")
}
// Load reads the config. A missing file is not an error: everything loomctl does
// against a public repository works with no credential at all.
// Load reads the config. A missing file is not an error: everything loomctl
// does against a public repository works with no credential and no context.
func Load() (*Config, error) {
c := &Config{Hosts: map[string]Host{}}
c := &Config{Contexts: map[string]Context{}}
p := Path()
if p == "" {
return c, nil
@@ -57,16 +64,31 @@ func Load() (*Config, error) {
if err := json.Unmarshal(b, c); err != nil {
return nil, fmt.Errorf("parsing %s: %w", p, err)
}
if c.Hosts == nil {
c.Hosts = map[string]Host{}
if c.Contexts == nil {
c.Contexts = map[string]Context{}
}
return c, nil
}
// Current returns the context a bare `owner/repo` is resolved against.
func (c *Config) Current() (Context, error) {
if c.CurrentContext == "" {
return Context{}, fmt.Errorf("no current-context in %s — a bare owner/repo has no host to resolve against", Path())
}
ctx, ok := c.Contexts[c.CurrentContext]
if !ok {
return Context{}, fmt.Errorf("current-context %q is not defined in %s", c.CurrentContext, Path())
}
if ctx.Host == "" {
return Context{}, fmt.Errorf("context %q has no host", c.CurrentContext)
}
return ctx, nil
}
// TokenFor returns the token for a host, or "" if we have none.
//
// An environment variable wins over the file, so a token can be supplied for one
// invocation without ever being written to disk.
// An environment variable wins over the file, so a token can be supplied for
// one invocation without ever being written to disk.
func (c *Config) TokenFor(host string) string {
if t := os.Getenv("LOOMCTL_TOKEN_" + envKey(host)); t != "" {
return t
@@ -74,11 +96,48 @@ func (c *Config) TokenFor(host string) string {
if t := os.Getenv("LOOMCTL_TOKEN"); t != "" {
return t
}
return c.Hosts[host].Token
for _, ctx := range c.Contexts {
if ctx.Host == host && ctx.Token != "" {
return ctx.Token
}
}
return ""
}
// envKey turns a hostname into the shape an environment variable can carry.
func envKey(host string) string {
r := strings.NewReplacer(".", "_", "-", "_", ":", "_")
return strings.ToUpper(r.Replace(host))
}
// RawURL builds the address a document is served from, given owner/repo and a
// path inside the repository.
//
// For gitea the short form is deliberate: the host redirects it to the resolved
// branch, so the URL recorded in the lock names a branch rather than a moving
// ref, without anybody having to know which branch it was.
func (c Context) RawURL(ownerRepo, pathInRepo string) (string, bool) {
switch c.flavor() {
case "gitea":
return fmt.Sprintf("https://%s/%s/raw/%s", c.Host, ownerRepo, pathInRepo), true
case "github":
return fmt.Sprintf("https://raw.githubusercontent.com/%s/%%s/%s", ownerRepo, pathInRepo), false
case "gitlab":
return fmt.Sprintf("https://%s/%s/-/raw/%%s/%s", c.Host, ownerRepo, pathInRepo), false
}
return "", false
}
// CloneURL is what git is pointed at.
func (c Context) CloneURL(ownerRepo string) string {
return fmt.Sprintf("https://%s/%s.git", c.Host, ownerRepo)
}
func (c Context) flavor() string {
if c.Flavor == "" {
return "gitea"
}
return strings.ToLower(c.Flavor)
}
// Flavors names what RawURL understands, for error messages.
func Flavors() string { return "gitea, github, gitlab" }
+110
View File
@@ -0,0 +1,110 @@
package external
import (
"bytes"
"errors"
"fmt"
"io"
"os/exec"
"strings"
"git.hypertheory-labs.dev/loom/loom-cli/internal/config"
)
// AddByName adopts a document named the way `list` prints it, against the
// current context.
//
// loomctl external add loom/cart cart
//
// Nobody should have to type a host's raw-file route to adopt a document. The
// route belongs to the host, so it lives in the context; the published
// directory belongs to the convention, so it lives in the code; and what is
// left — which repository, which document — is the only part a person knows.
func AddByName(root, ownerRepo, name string, out io.Writer) error {
cfg, err := config.Load()
if err != nil {
return err
}
ctx, err := cfg.Current()
if err != nil {
return err
}
if strings.Count(ownerRepo, "/") != 1 {
return fmt.Errorf("want owner/repo, got %q", ownerRepo)
}
if !strings.HasSuffix(name, ".md") {
name += ".md"
}
raw, resolved := ctx.RawURL(ownerRepo, PublishedDir+"/"+name)
if raw == "" {
return fmt.Errorf("context %q has flavor %q; known flavors are %s",
cfg.CurrentContext, ctx.Flavor, config.Flavors())
}
if !resolved {
// The lock must record a resolved URL. A short form that stays short
// follows whatever the default branch is at the time you ask, so a
// branch rename would report as a change in the document.
branch, err := defaultBranch(ctx.CloneURL(ownerRepo))
if err != nil {
return fmt.Errorf("resolving the default branch of %s: %w", ownerRepo, err)
}
raw = fmt.Sprintf(raw, branch)
}
err = Add(root, raw, "", out)
// A 404 here has a reading the convention does not list, because it can only
// happen at adoption: the name is wrong. We know the repository, so rather
// than reporting an ambiguity we can resolve, look.
var nf *NotFoundError
if errors.As(err, &nf) {
var names strings.Builder
if lerr := List(ctx.CloneURL(ownerRepo), &names); lerr == nil {
return fmt.Errorf("%s publishes no %q. It publishes:\n%s",
ownerRepo, name, indent(names.String()))
}
// Listing failed too, so the repository itself is unreachable and the
// original ambiguity stands.
}
return err
}
func indent(s string) string {
var b strings.Builder
for _, line := range strings.Split(strings.TrimRight(s, "\n"), "\n") {
fmt.Fprintf(&b, " %s\n", line)
}
return strings.TrimRight(b.String(), "\n")
}
// ListByName enumerates a repository named against the current context.
func ListByName(ownerRepo string, out io.Writer) error {
cfg, err := config.Load()
if err != nil {
return err
}
ctx, err := cfg.Current()
if err != nil {
return err
}
return List(ctx.CloneURL(ownerRepo), out)
}
// defaultBranch asks the remote which branch HEAD points at, without cloning.
//
// Only hosts whose raw URLs do not redirect need this. Gitea resolves its own
// short form, which is why adopting from gitea needs no git at all.
func defaultBranch(cloneURL string) (string, error) {
cmd := exec.Command("git", "ls-remote", "--symref", cloneURL, "HEAD")
var stdout, stderr bytes.Buffer
cmd.Stdout, cmd.Stderr = &stdout, &stderr
if err := cmd.Run(); err != nil {
return "", fmt.Errorf("%w: %s", err, strings.TrimSpace(stderr.String()))
}
for _, line := range strings.Split(stdout.String(), "\n") {
if after, ok := strings.CutPrefix(line, "ref: refs/heads/"); ok {
return strings.TrimSpace(strings.SplitN(after, "\t", 2)[0]), nil
}
}
return "", fmt.Errorf("no symref in git ls-remote output")
}
+68
View File
@@ -0,0 +1,68 @@
package external
import (
"fmt"
"io"
"git.hypertheory-labs.dev/loom/loom-cli/internal/lock"
)
// auditExposure reports documents adopted from a source that could not be read
// anonymously, into a repository that now can.
//
// Access is verified once, at fetch, and the copy is durable. Whether the
// adoption is still legitimate rests on the relative visibility of two
// repositories — a fact somebody can change with a checkbox a year later,
// without ever seeing the adoption. This is what turns that from a silent
// permanent hazard into something that runs.
func auditExposure(root string, locks *lock.Set, out io.Writer) (changed bool) {
var suspect []lock.Record
for _, r := range locks.All() {
if r.Visibility == lock.NotPublic {
suspect = append(suspect, r)
}
}
if len(suspect) == 0 {
return false
}
// Only our own visibility has to be current, and it is one request for the
// whole run rather than one per document.
public, known := selfVisibility(root)
if known && !public {
return false // adopted private into private; nothing has widened
}
if !known {
fmt.Fprintf(out, "\n%d document(s) came from a source that needed a credential, and I cannot\n", len(suspect))
fmt.Fprintf(out, "tell who may read this repository — no usable origin.\n")
return false
}
// The stored value decays in both directions. A source that has since gone
// public would otherwise raise this alarm forever, so re-check — but only
// the suspects, and only when the alarm would actually fire.
var still []lock.Record
for _, r := range suspect {
// Probe with no credential: what matters is what a stranger can read,
// not what we can.
if probeAnonymous(r.URL) == lock.Public {
r.Visibility = lock.Public
locks.Put(r)
changed = true
continue
}
still = append(still, r)
}
if len(still) == 0 {
return changed
}
fmt.Fprintf(out, "\nEXPOSURE this repository is public and holds %d document(s) adopted from\n", len(still))
fmt.Fprintf(out, " sources that are not:\n")
for _, r := range still {
fmt.Fprintf(out, " %s\n", r.Path)
}
fmt.Fprintf(out, " Confidentiality does not travel with the copy. This was legitimate\n")
fmt.Fprintf(out, " when adopted if this repository was not public then.\n")
return changed
}
+72 -15
View File
@@ -119,6 +119,7 @@ func Add(root, raw, override string, out io.Writer) error {
if body == nil {
return fmt.Errorf("%s: unexpected 304 for a document we do not have", raw)
}
vis := sourceVisibility(cfg, body.url)
// A document that is here but unlocked was fetched by hand before the tool
// existed. Supplying its URL is the only way it can ever be locked, because
@@ -145,7 +146,7 @@ func Add(root, raw, override string, out io.Writer) error {
}
// Identical, so the assertion a lock makes — this local copy is the one
// being served — is verified rather than assumed.
locks.Put(lock.Record{Path: rel, URL: body.url, ETag: etag})
locks.Put(lock.Record{Path: rel, URL: body.url, ETag: etag, Visibility: vis})
if err := locks.Save(); err != nil {
return err
}
@@ -161,14 +162,15 @@ func Add(root, raw, override string, out io.Writer) error {
if err := os.WriteFile(dest, body.data, 0o644); err != nil {
return err
}
locks.Put(lock.Record{Path: rel, URL: body.url, ETag: etag})
locks.Put(lock.Record{Path: rel, URL: body.url, ETag: etag, Visibility: vis})
if err := locks.Save(); err != nil {
return err
}
fmt.Fprintf(out, "adopted %s\n", rel)
fmt.Fprintf(out, " from %s\n", body.url)
warnIfNotPublic(cfg, body.url, out)
warnIfNotPublic(root, body.url, vis, out)
notePublishedSurface(body.url, out)
if etag == "" {
fmt.Fprintf(out, " etag (none served — check cannot ask conditionally)\n")
} else {
@@ -185,32 +187,85 @@ func Add(root, raw, override string, out io.Writer) error {
// moment of adoption. The tool can see half of that — whether this fetch needed
// a credential — and cannot see the other half, which is who can read the
// repository the copy is landing in. It reports the half it knows.
func warnIfNotPublic(cfg *config.Config, raw string, out io.Writer) {
// sourceVisibility reports what a document could be read as at the moment it was
// fetched, given that the fetch had just succeeded.
//
// It is free: when no credential was configured the fetch itself was anonymous,
// so the answer is already known; when one was, the extra request is the one the
// warning needed anyway.
//
// It must not be used to re-check a document we are not fetching. "No credential
// configured" says nothing about whether a probe would succeed, and treating it
// as public there would silently clear a real alarm.
func sourceVisibility(cfg *config.Config, raw string) string {
if u, err := url.Parse(raw); err == nil && cfg.TokenFor(u.Host) == "" {
return lock.Public // it came back without a credential
}
return probeAnonymous(raw)
}
// probeAnonymous asks, with no credential at all, whether a URL can be read.
func probeAnonymous(raw string) string {
req, err := http.NewRequest(http.MethodHead, raw, nil)
if err != nil {
return
}
if cfg.TokenFor(req.URL.Host) == "" {
return // no credential was used, so the fetch was already anonymous
return ""
}
resp, err := client.Do(req)
if err != nil {
return
return ""
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusOK {
return lock.Public
}
return lock.NotPublic
}
// warnIfNotPublic says something when adopting would widen who can read a
// document.
//
// Adopting is copying, so a document from a repository somebody may not read
// ends up in a repository they may, and the publisher loses control of it at the
// moment of adoption.
func warnIfNotPublic(root, raw, vis string, out io.Writer) {
if vis != lock.NotPublic {
return
}
fmt.Fprintf(out, " WARN this needed a credential — anonymously it is %s.\n", resp.Status)
fmt.Fprintf(out, " Confidentiality does not travel with the copy: this now lives in\n")
fmt.Fprintf(out, " .loom/externals/ and is readable by anyone who can read THIS\n")
fmt.Fprintf(out, " repository, which I cannot see. Do not adopt from a source less\n")
public, known := selfVisibility(root)
if known && !public {
fmt.Fprintf(out, " NOTE private source, and this repository is not public either.\n")
fmt.Fprintf(out, " Access is not widened by that alone — but this check only\n")
fmt.Fprintf(out, " tells public from not-public, so it cannot see two repositories\n")
fmt.Fprintf(out, " private to different people. That case does widen it.\n")
return
}
if !known {
fmt.Fprintf(out, " WARN this needed a credential, and I cannot tell who may read this\n")
fmt.Fprintf(out, " repository — no usable origin. Check before you commit.\n")
return
}
fmt.Fprintf(out, " WARN this needed a credential, and THIS repository is public.\n")
fmt.Fprintf(out, " Confidentiality does not travel with the copy: adopting this\n")
fmt.Fprintf(out, " publishes it to everyone. Do not adopt from a source less\n")
fmt.Fprintf(out, " readable than the repository you are adopting into.\n")
fmt.Fprintf(out, " Two ways out: ask them to publish it — usually the thing you\n")
fmt.Fprintf(out, " needed was not the confidential part — or keep no copy and\n")
fmt.Fprintf(out, " record only the dependency, which loomctl cannot do yet.\n")
}
// NotFoundError is a 404, which over HTTP carries more than one reading.
//
// The convention names two — withdrawn, or access lost — because those are the
// two a locked document can have. Adoption by name has a third: a document that
// was never there under that name. Callers that know the repository can tell
// them apart; this type is how they get the chance.
type NotFoundError struct{ URL string }
func (e *NotFoundError) Error() string {
return fmt.Sprintf("404 unresolved: %s was withdrawn, or this credential cannot see it — "+
"over HTTP these are the same response", e.URL)
}
type fetched struct {
data []byte
url string
@@ -239,8 +294,7 @@ func fetch(cfg *config.Config, raw, ifNoneMatch string) (*fetched, string, error
}
return &fetched{data: b, url: resp.Request.URL.String()}, resp.Header.Get("ETag"), nil
case http.StatusNotFound:
return nil, "", fmt.Errorf("404 unresolved: %s was withdrawn, or this credential cannot see it — "+
"over HTTP these are the same response", raw)
return nil, "", &NotFoundError{URL: raw}
default:
return nil, "", fmt.Errorf("%s: %s", resp.Status, raw)
}
@@ -289,6 +343,8 @@ func Check(root string, out io.Writer) error {
staged += did
results = append(results, st)
}
auditPrinted := &strings.Builder{}
auditExposure(root, locks, auditPrinted)
if err := locks.Save(); err != nil {
return err
}
@@ -309,6 +365,7 @@ func Check(root string, out io.Writer) error {
if staged > 0 {
fmt.Fprintf(out, "\n%d staged in %s — apply or discard; nothing here drifts into being kept.\n", staged, PoladDir)
}
io.WriteString(out, auditPrinted.String())
return nil
}
+97
View File
@@ -0,0 +1,97 @@
package external
import (
"bytes"
"fmt"
"net/http"
"os/exec"
"strings"
)
// selfVisibility reports whether the repository we are adopting into can be read
// anonymously.
//
// This is the half of the confidentiality rule the tool was previously blind to.
// Knowing only that a source is private makes the warning fire on every adoption
// a private repository performs, which is the legitimate case — and a warning
// that always fires is a warning nobody reads.
//
// The signal is coarse on purpose. It distinguishes public from not-public and
// nothing finer, so it cannot see that two repositories are private to different
// groups. That case widens access and this check will miss it.
func selfVisibility(root string) (public bool, known bool) {
out, err := exec.Command("git", "-C", root, "remote", "get-url", "origin").Output()
if err != nil {
return false, false
}
host, ownerRepo, ok := splitRemote(strings.TrimSpace(string(out)))
if !ok {
return false, false
}
req, err := http.NewRequest(http.MethodHead, "https://"+host+"/"+ownerRepo, nil)
if err != nil {
return false, false
}
resp, err := client.Do(req)
if err != nil {
return false, false
}
defer resp.Body.Close()
return resp.StatusCode == http.StatusOK, true
}
// splitRemote pulls a host and owner/repo out of a git remote, whether it is
// ssh, ssh:// or https.
func splitRemote(remote string) (host, ownerRepo string, ok bool) {
s := remote
if i := strings.Index(s, "://"); i >= 0 {
s = s[i+3:]
}
if at := strings.Index(s, "@"); at >= 0 {
s = s[at+1:]
}
// scp-style "host:owner/repo.git" and url-style "host:port/owner/repo.git"
var rest string
if i := strings.IndexAny(s, ":/"); i >= 0 {
host, rest = s[:i], s[i+1:]
} else {
return "", "", false
}
if j := strings.Index(rest, "/"); j >= 0 && isPort(rest[:j]) {
rest = rest[j+1:]
}
rest = strings.TrimSuffix(strings.Trim(rest, "/"), ".git")
if host == "" || strings.Count(rest, "/") != 1 {
return "", "", false
}
return host, rest, true
}
func isPort(s string) bool {
if s == "" {
return false
}
for _, r := range s {
if r < '0' || r > '9' {
return false
}
}
return true
}
// notePublishedSurface says when an adopted document did not come from the
// publisher's published surface.
//
// What is not exported is not hidden — the rest of a repository is there to read.
// It is simply not what you depend on, and a lock against it records a dependency
// on something that was never a contract.
func notePublishedSurface(rawURL string, out interface{ Write([]byte) (int, error) }) {
if strings.Contains(rawURL, "/"+PublishedDir+"/") {
return
}
var b bytes.Buffer
fmt.Fprintf(&b, " NOTE not from %s — what is not exported is not hidden, but it is\n", PublishedDir)
fmt.Fprintf(&b, " not what you depend on. Nothing promises this path will still\n")
fmt.Fprintf(&b, " be there, or still mean this, tomorrow.\n")
out.Write(b.Bytes())
}
+34
View File
@@ -0,0 +1,34 @@
package external
import "testing"
func TestSplitRemote(t *testing.T) {
for _, tc := range []struct {
remote, host, ownerRepo string
ok bool
}{
{"ssh://git@git.hypertheory-labs.dev:2222/loom/loom-cli.git", "git.hypertheory-labs.dev", "loom/loom-cli", true},
{"ssh://git@git.hypertheory-labs.dev/loom/loom-cli.git", "git.hypertheory-labs.dev", "loom/loom-cli", true},
{"git@github.com:octocat/Hello-World.git", "github.com", "octocat/Hello-World", true},
{"https://gitlab.com/gitlab-org/gitlab-svgs.git", "gitlab.com", "gitlab-org/gitlab-svgs", true},
{"https://gitlab.com/gitlab-org/gitlab-svgs", "gitlab.com", "gitlab-org/gitlab-svgs", true},
// A nested group is not owner/repo, and guessing would produce a URL
// that answers about the wrong repository.
{"https://gitlab.com/group/sub/project.git", "", "", false},
{"/srv/git/bare.git", "", "", false},
{"", "", "", false},
} {
host, or, ok := splitRemote(tc.remote)
if ok != tc.ok || host != tc.host || or != tc.ownerRepo {
t.Errorf("splitRemote(%q) = (%q, %q, %v), want (%q, %q, %v)",
tc.remote, host, or, ok, tc.host, tc.ownerRepo, tc.ok)
}
}
}
func TestSelfVisibilityUnknownOutsideARepo(t *testing.T) {
// Failing open matters: "cannot tell" must not read as "not public".
if _, known := selfVisibility(t.TempDir()); known {
t.Error("selfVisibility in a non-repository reported a known answer")
}
}
+36 -6
View File
@@ -25,8 +25,10 @@ const Dir = ".loom/externals"
const File = ".locks"
const header = "# loomctl locks — one record per adopted document.\n" +
"# path<TAB>url<TAB>etag The url is resolved: a short form would follow\n" +
"# whatever the default branch is at the time you ask.\n"
"# path<TAB>url<TAB>etag[<TAB>visibility]\n" +
"# The url is resolved: a short form would follow whatever the default branch\n" +
"# is at the time you ask. visibility is what the source could be read as when\n" +
"# it was fetched, because that is checked once and the copy is durable.\n"
// Record is one adopted document.
type Record struct {
@@ -38,8 +40,28 @@ type Record struct {
URL string
// ETag is the publisher's, verbatim, including its quotes.
ETag string
// Visibility is what the source could be read as when it was fetched:
// "public", "not-public", or empty for locks written before this was
// recorded.
//
// It is deliberately coarse. An anonymous request tells public from
// not-public and nothing finer, so this cannot distinguish two repositories
// private to different people — which is the case where adopting between
// private repositories genuinely widens access.
//
// It is recorded because access is checked once, at fetch, and the copy is
// durable. Whether an adoption is still legitimate depends on the relative
// visibility of two repositories, which somebody can change with a checkbox
// a year later without ever seeing the adoption.
Visibility string
}
// Visibility values. Never "private": the signal cannot support the word.
const (
Public = "public"
NotPublic = "not-public"
)
// Set is every lock, keyed by path.
type Set struct {
file string
@@ -75,10 +97,14 @@ func LoadFile(file string) (*Set, error) {
continue
}
parts := strings.Split(line, "\t")
if len(parts) != 3 {
return nil, fmt.Errorf("%s:%d: want 3 tab-separated fields, got %d", file, n, len(parts))
if len(parts) != 3 && len(parts) != 4 {
return nil, fmt.Errorf("%s:%d: want 3 or 4 tab-separated fields, got %d", file, n, len(parts))
}
s.recs[parts[0]] = Record{Path: parts[0], URL: parts[1], ETag: parts[2]}
r := Record{Path: parts[0], URL: parts[1], ETag: parts[2]}
if len(parts) == 4 {
r.Visibility = parts[3]
}
s.recs[parts[0]] = r
}
return s, sc.Err()
}
@@ -112,7 +138,11 @@ func (s *Set) Save() error {
var b strings.Builder
b.WriteString(header)
for _, r := range s.All() {
fmt.Fprintf(&b, "%s\t%s\t%s\n", r.Path, r.URL, r.ETag)
if r.Visibility == "" {
fmt.Fprintf(&b, "%s\t%s\t%s\n", r.Path, r.URL, r.ETag)
continue
}
fmt.Fprintf(&b, "%s\t%s\t%s\t%s\n", r.Path, r.URL, r.ETag, r.Visibility)
}
tmp, err := os.CreateTemp(filepath.Dir(p), ".locks-*")
if err != nil {
+57
View File
@@ -0,0 +1,57 @@
package lock
import (
"os"
"path/filepath"
"strings"
"testing"
)
// Locks written before visibility was recorded have three fields, and must keep
// loading: a repository does not get to stop working because the tool learned
// something new.
func TestLoadsThreeAndFourFieldRecords(t *testing.T) {
root := t.TempDir()
p := Path(root)
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
t.Fatal(err)
}
body := "# comment\n\nh/o/r/old.md\thttps://h/old\t\"1\"\n" +
"h/o/r/new.md\thttps://h/new\t\"2\"\tnot-public\n"
if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
s, err := Load(root)
if err != nil {
t.Fatal(err)
}
old, _ := s.Get("h/o/r/old.md")
if old.Visibility != "" {
t.Errorf("a three-field record should have unknown visibility, got %q", old.Visibility)
}
nw, _ := s.Get("h/o/r/new.md")
if nw.Visibility != NotPublic {
t.Errorf("visibility = %q, want %q", nw.Visibility, NotPublic)
}
// And unknown must survive a round trip rather than being written as a value.
if err := s.Save(); err != nil {
t.Fatal(err)
}
out, _ := os.ReadFile(p)
for _, line := range strings.Split(string(out), "\n") {
if strings.HasPrefix(line, "h/o/r/old.md") && strings.Count(line, "\t") != 2 {
t.Errorf("unknown visibility was written as a field: %q", line)
}
}
}
func TestRejectsAMalformedRecord(t *testing.T) {
root := t.TempDir()
p := Path(root)
os.MkdirAll(filepath.Dir(p), 0o755)
os.WriteFile(p, []byte("h/o/r/x.md\thttps://h/x\n"), 0o644)
if _, err := Load(root); err == nil {
t.Error("a two-field record should be an error, not a silently empty ETag")
}
}
+109
View File
@@ -0,0 +1,109 @@
// Package orient generates a table of contents over what a repository depends
// on, for whoever arrives next — a person, or an agent of any make.
//
// It restates no rule. Every rule it might repeat is owned by a document already
// in the working tree, and a copy of a rule is a copy that goes stale: this file
// says only what is here, where it came from, and where the rules live.
//
// The output is byte-deterministic. A generated file that churns produces diffs
// nobody reads, and the diff is most of the value — it is how somebody sees that
// their dependencies moved.
package orient
import (
"fmt"
"os"
"path"
"path/filepath"
"strings"
"git.hypertheory-labs.dev/loom/loom-cli/internal/lock"
)
// File is where the orientation lives, relative to the repository root.
//
// Beside .loom/event-log.md rather than inside .loom/externals/, because
// everything in that directory is somebody else's document — which is what makes
// "do not edit these" a rule you can state in four words — and because `check`
// walks it and would report a generated file as an unlocked external forever.
const File = ".loom/orientation.md"
const preamble = `<!-- Generated by ` + "`loomctl orient`" + `. Safe to overwrite; nobody typed it.
Regenerate after adopting a document or reconciling a change. -->
# What this repository depends on
Copies of other people's documents are kept under ` + "`.loom/externals/`" + `, at a path
that says where each came from. **They are copies: do not edit them.** Anything
you want to say about one goes in a file *beside* it, never into it.
Three moves, and each has a document that owns the rule:
- **A copy is wrong, or you needed something it does not say** — write it in
` + "`<name>.gaps.md`" + ` beside the copy.
- **What of ours depends on a copy** — write it in ` + "`<name>.usages.md`" + ` beside it.
- **A source changed** — ` + "`loomctl external check`" + ` says so and stages the new copy;
` + "`loomctl external apply`" + ` takes it. Neither edits anything on its own.
`
// Generate writes the orientation for the repository at root.
func Generate(root, out string) (string, error) {
locks, err := lock.Load(root)
if err != nil {
return "", err
}
var b strings.Builder
b.WriteString(preamble)
recs := locks.All() // already ordered by path
if len(recs) == 0 {
b.WriteString("\nThis repository adopts nothing yet.\n")
}
for _, r := range recs {
fmt.Fprintf(&b, "\n## `%s`\n\n", r.Path)
fmt.Fprintf(&b, "- source: %s\n", r.URL)
facets := facetsFor(root, r.Path)
if u, ok := facets["usages"]; ok {
fmt.Fprintf(&b, "- what of ours depends on it: `%s`\n", u)
} else {
fmt.Fprintf(&b, "- what of ours depends on it: **nothing recorded** — no `.usages.md`\n")
}
if g, ok := facets["gaps"]; ok {
fmt.Fprintf(&b, "- what we expected and did not find: `%s`\n", g)
}
}
if dir := filepath.Join(root, ".loom", "published"); isDir(dir) {
b.WriteString("\n## This repository publishes\n\n")
b.WriteString("See `.loom/published/`. What is handed over for others to depend on is\n")
b.WriteString("there; the rest of the repository is not hidden, it is simply not what\n")
b.WriteString("anybody depends on.\n")
}
dest := out
if dest == "" {
dest = filepath.Join(root, filepath.FromSlash(File))
}
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
return "", err
}
return dest, os.WriteFile(dest, []byte(b.String()), 0o644)
}
// facetsFor finds the files written beside an adopted document.
func facetsFor(root, rel string) map[string]string {
found := map[string]string{}
stem := strings.TrimSuffix(rel, path.Ext(rel))
for _, kind := range []string{"usages", "gaps"} {
p := stem + "." + kind + ".md"
if _, err := os.Stat(filepath.Join(root, lock.Dir, filepath.FromSlash(p))); err == nil {
found[kind] = lock.Dir + "/" + p
}
}
return found
}
func isDir(p string) bool {
fi, err := os.Stat(p)
return err == nil && fi.IsDir()
}
+73
View File
@@ -0,0 +1,73 @@
package orient
import (
"os"
"path/filepath"
"strings"
"testing"
"git.hypertheory-labs.dev/loom/loom-cli/internal/lock"
)
// A generated file that churns produces diffs nobody reads, and the diff is most
// of the value.
func TestGenerateIsDeterministic(t *testing.T) {
root := t.TempDir()
locks, err := lock.LoadFile(lock.Path(root))
if err != nil {
t.Fatal(err)
}
// Inserted out of order on purpose: the output must not depend on it.
for _, r := range []lock.Record{
{Path: "h/o/zeta/z.md", URL: "https://h/z", ETag: `"3"`},
{Path: "h/o/alpha/a.md", URL: "https://h/a", ETag: `"1"`},
{Path: "h/o/mid/m.md", URL: "https://h/m", ETag: `"2"`},
} {
locks.Put(r)
}
if err := locks.Save(); err != nil {
t.Fatal(err)
}
var first string
for i := 0; i < 3; i++ {
dest, err := Generate(root, filepath.Join(root, "out.md"))
if err != nil {
t.Fatal(err)
}
b, err := os.ReadFile(dest)
if err != nil {
t.Fatal(err)
}
if i == 0 {
first = string(b)
continue
}
if string(b) != first {
t.Fatal("output changed between runs")
}
}
if a, z := strings.Index(first, "alpha"), strings.Index(first, "zeta"); a > z {
t.Error("entries are not ordered by path")
}
if strings.Contains(first, "publishes") {
t.Error("claimed the repository publishes with no .loom/published")
}
}
func TestSaysWhenNothingRecordsADependency(t *testing.T) {
root := t.TempDir()
locks, _ := lock.LoadFile(lock.Path(root))
locks.Put(lock.Record{Path: "h/o/r/doc.md", URL: "https://h/doc", ETag: `"1"`})
if err := locks.Save(); err != nil {
t.Fatal(err)
}
dest, err := Generate(root, filepath.Join(root, "out.md"))
if err != nil {
t.Fatal(err)
}
b, _ := os.ReadFile(dest)
if !strings.Contains(string(b), "nothing recorded") {
t.Error("a document with no .usages.md should say so — it is a finding, not an omission")
}
}
+118 -14
View File
@@ -9,15 +9,22 @@ package main
import (
"flag"
"fmt"
"io"
"os"
"sort"
"strings"
"git.hypertheory-labs.dev/loom/loom-cli/internal/config"
"git.hypertheory-labs.dev/loom/loom-cli/internal/external"
"git.hypertheory-labs.dev/loom/loom-cli/internal/orient"
)
const usage = `loomctl — fetch what you depend on, and find out when it changed.
loomctl external list <repo-url> what a repository publishes
loomctl external add <url> [--path p] adopt one document and lock it
loomctl external list <owner/repo> what a repository publishes
loomctl external add <owner/repo> <doc> adopt one document and lock it
loomctl external add <url> [--path p] ... or by its full raw URL
loomctl external check ask every publisher whether theirs moved
loomctl external apply [path...] move a staged polad into place, lock and all
@@ -27,9 +34,12 @@ become — and somebody decides. Its exits are apply or discard; nothing there
drifts into being kept. With no cart open, check says what moved and stages
nothing, because opening a round is somebody's act and not a side effect.
add warns when a document could only be fetched with a credential: adopting is
copying, and confidentiality does not travel with the copy. It cannot see who
may read the repository the copy lands in, so it reports the half it knows.
loomctl grants no access. It reads what your credentials already let you read,
and records where the copy came from — which is the part copy-and-paste loses.
It warns rather than refuses: adopting from a source less readable than the
repository you are adopting into is yours to decide, and the check only tells
public from not-public, so it cannot see two repositories private to different
people.
add adopts what is not here yet, and refuses what is already adopted. Given a
document that is present but unlocked — fetched by hand before this existed —
@@ -41,9 +51,25 @@ Adopted documents live in .loom/externals/<host>/<owner>/<repo>/<name>.md, and
their origins in .loom/externals/.locks. The path is for a person to read; the
lock is what a machine uses, because the path does not round-trip to a URL.
Credentials are read-only and per host, in ~/.config/loomctl/config.json or in
LOOMCTL_TOKEN_<HOST>. loomctl never writes over the network, so a token it is
given should never carry write scope.
loomctl orient [--out path] write .loom/orientation.md for whoever arrives next
loomctl config which context is current, and from where
A bare owner/repo is resolved against the current context in
~/.config/loomctl/config.json, which holds a host, its flavor and a read-only
token. The host's raw-file route lives there so that nobody has to type it, and
LOOMCTL_TOKEN_<HOST> overrides the file for one invocation. loomctl never writes
over the network, so a token it is given should never carry write scope.
{
"current-context": "hypertheory",
"contexts": {
"hypertheory": {
"host": "git.hypertheory-labs.dev",
"flavor": "gitea",
"token": "..."
}
}
}
Requires git on PATH, for list only.
`
@@ -63,6 +89,24 @@ func run(args []string) error {
switch args[0] {
case "external":
return runExternal(args[1:])
case "orient":
fs := flag.NewFlagSet("orient", flag.ContinueOnError)
out := fs.String("out", "", "write here instead of "+orient.File)
if err := fs.Parse(args[1:]); err != nil {
return err
}
root, err := root()
if err != nil {
return err
}
dest, err := orient.Generate(root, *out)
if err != nil {
return err
}
fmt.Fprintf(os.Stdout, "wrote %s\n", dest)
return nil
case "config":
return showConfig(os.Stdout)
default:
return fmt.Errorf("unknown command %q\n\n%s", args[0], usage)
}
@@ -75,9 +119,12 @@ func runExternal(args []string) error {
switch args[0] {
case "list":
if len(args) != 2 {
return fmt.Errorf("usage: loomctl external list <repo-url>")
return fmt.Errorf("usage: loomctl external list <owner/repo>")
}
return external.List(args[1], os.Stdout)
if strings.Contains(args[1], "://") {
return external.List(args[1], os.Stdout)
}
return external.ListByName(args[1], os.Stdout)
case "add":
fs := flag.NewFlagSet("add", flag.ContinueOnError)
@@ -85,14 +132,23 @@ func runExternal(args []string) error {
if err := fs.Parse(args[1:]); err != nil {
return err
}
if fs.NArg() != 1 {
return fmt.Errorf("usage: loomctl external add <url> [--path p]")
}
root, err := root()
if err != nil {
return err
}
return external.Add(root, fs.Arg(0), *path, os.Stdout)
switch fs.NArg() {
case 1:
if !strings.Contains(fs.Arg(0), "://") {
return fmt.Errorf("adopting by name needs the document too: " +
"loomctl external add <owner/repo> <doc>")
}
return external.Add(root, fs.Arg(0), *path, os.Stdout)
case 2:
return external.AddByName(root, fs.Arg(0), fs.Arg(1), os.Stdout)
default:
return fmt.Errorf("usage: loomctl external add <owner/repo> <doc>, " +
"or loomctl external add <url> [--path p]")
}
case "check":
root, err := root()
@@ -113,6 +169,54 @@ func runExternal(args []string) error {
}
}
// showConfig says which context is current and where it came from, so that "my
// token is not being used" is answerable without printing the token.
func showConfig(out io.Writer) error {
cfg, err := config.Load()
if err != nil {
return err
}
fmt.Fprintf(out, "config %s\n", config.Path())
if len(cfg.Contexts) == 0 {
fmt.Fprintln(out, " (none — public repositories still work by full URL)")
return nil
}
names := make([]string, 0, len(cfg.Contexts))
for n := range cfg.Contexts {
names = append(names, n)
}
sort.Strings(names)
for _, n := range names {
c := cfg.Contexts[n]
marker := " "
if n == cfg.CurrentContext {
marker = "*"
}
flavor := c.Flavor
if flavor == "" {
flavor = "gitea (default)"
}
fmt.Fprintf(out, "%s %-14s %-32s %-16s %s\n", marker, n, c.Host, flavor, credential(cfg, c))
}
if _, err := cfg.Current(); err != nil {
fmt.Fprintf(out, "\n%v\n", err)
}
return nil
}
func credential(cfg *config.Config, c config.Context) string {
if os.Getenv("LOOMCTL_TOKEN") != "" {
return "token from LOOMCTL_TOKEN"
}
if cfg.TokenFor(c.Host) != "" {
if c.Token == "" {
return "token from the environment"
}
return "token set"
}
return "no token (anonymous)"
}
func root() (string, error) {
wd, err := os.Getwd()
if err != nil {