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
This commit is contained in:
2026-09-08 09:11:04 -04:00
co-authored by Claude Opus 5
parent 1cc528c8aa
commit da6e8b9b51
4 changed files with 263 additions and 0 deletions
+63
View File
@@ -0,0 +1,63 @@
<!-- 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`
+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")
}
}
+18
View File
@@ -17,6 +17,7 @@ import (
"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.
@@ -50,6 +51,7 @@ 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.
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
@@ -87,6 +89,22 @@ 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: