Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .abcd/development/release/surface.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,13 @@
"required": false,
"hidden": false
},
{
"name": "dev",
"shorthand": "",
"type": "bool",
"required": false,
"hidden": false
},
{
"name": "docs-target",
"shorthand": "",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ severity: "minor"
category: "observation"
source: "user-observation"
found_during: "abcd-plugin-dogfooding"
resolution: "Built abcd ahoy install --dev: a track-latest shim rebuilds from source tip and execs the fresh binary, failing loudly on a broken build, replacing the hand-rolled wrapper. Mode surfaced by status from the installed shim; transitions apply-as-update both directions."
impact: additive
---

abcd lacks an always-latest / dev install mode for dogfooding: 'abcd ahoy install' symlinks a pinned built binary and there is no plain bin/abcd, so tracking live development required a hand-rolled ~/.local/bin/abcd wrapper that runs 'go build -C <repo> && exec' on each call (rebuild-from-source, fail loudly on a broken build). This manual workaround means abcd's own install path does not cover the dogfooding case. Consider 'abcd ahoy install --dev' / a track-latest mode, or bless+document the wrapper as the sanctioned dogfood path. Recorded per the golden rule: a manual workaround must be captured, never silently bypassed, so abcd can be fixed.
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,19 @@ called out in a **Breaking** section.

### Added

- **`abcd ahoy install --dev` — a track-latest dogfood install mode** (iss-75).
Normal `ahoy install` symlinks the pinned built binary, so tracking live
development meant hand-rolling a `~/.local/bin/abcd` wrapper that ran
`go build -C <repo> && exec` on every call. That manual workaround now dies:
`--dev` installs a shim at the same `PATH` target that rebuilds abcd from the
source tip on every invocation and execs the fresh binary. A broken build fails
loudly and never execs a stale binary (loud-staging). `abcd ahoy` status reports
the mode as `install: dev (tip build)`, detected from the installed shim itself
(never recorded in the tracked repo config, so it can never go stale), so a dev
install is never invisible. Installing over an existing
install applies-as-update in either direction — `--dev` replaces the pinned
symlink with the shim, a plain re-install restores the symlink — and a foreign
occupant is still never clobbered.
- **Record-id minting now sees every branch, and a spec-id uniqueness lint closes
the class** (iss-115, iss-120). Sequential ids (`iss-N`, `itd-N`, `spc-N`) were
minted from the local working tree only, so two branches cut from the same base
Expand Down
9 changes: 9 additions & 0 deletions commands/abcd/ahoy.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,22 @@ Then summarise the JSON for the user:

- `folder_kind` — `managed-repo`, `unmanaged-repo`, or `unmanaged-folder`.
- `plugin_root_status` and `root_sha` — where abcd is anchored.
- `signals.install_mode` — the PATH-entry install mode: `dev (tip build)` when
the track-latest dogfood shim is installed, `pinned` for the built-binary
symlink, empty when there is nothing on `PATH` yet. Report it so a dev install
is never invisible.
- `gaps` — how many are outstanding, and for each actionable one its `title`,
`category`, and `fix_hint`; call out which are `required`.

If there are actionable gaps, tell the user to run `abcd ahoy install` to apply
them. If `folder_kind` is `unmanaged-folder`, note there is nothing to act on
(not a git repo, no abcd markers).

For dogfooding abcd itself, `abcd ahoy install --dev` installs a track-latest
shim instead of the pinned-binary symlink: the `PATH` entry rebuilds abcd from
the source tip on every call and fails loudly on a broken build. Re-running
`abcd ahoy install` without `--dev` switches back to the pinned symlink.

If the `abcd` binary is not on `PATH`, fall back to
`go run ./cmd/abcd ahoy --json` from the repo root, or tell the user to build it
with `make build`.
Expand Down
1 change: 1 addition & 0 deletions docs/reference/cli/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ Install or update abcd in this repo (idempotent)

```
--adopt adopt an unmanaged repo without prompting
--dev track-latest dogfood mode: the PATH entry rebuilds from the source tip on every call instead of pinning the built binary
--docs-target string marker target: claude_md | agents_md | both | skip
--oracle-backend string oracle backend: host-delegated | native | cli | api | mcp
--refuse-adopt decline to adopt an unmanaged repo
Expand Down
5 changes: 5 additions & 0 deletions internal/core/ahoy/ahoy.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,11 @@ type InstallOptions struct {
Yes bool // approve every resolvable category
ApprovedCategories map[GapCategory]bool // nil => interactive; explicit => partial subset
ValueOverrides map[string]string // visibility/docs_target/oracle_backend/scan_deep
// Dev requests the track-latest dogfood mode: the PATH entry becomes a shim
// that rebuilds abcd from the source tip on every call and execs the fresh
// binary (failing loudly on a broken build), instead of a symlink to the
// pinned built binary. --dev.
Dev bool
}

// InstallResult is the outcome of Install.
Expand Down
113 changes: 104 additions & 9 deletions internal/core/ahoy/apply.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,16 @@ func Install(cwd string, opts InstallOptions, p Prompter) (InstallResult, error)
// interactive confirmation (never under --yes), as the gap's fix hint
// advertises; and an explicit value override that differs from the persisted
// config, which forces an apply-as-update on an otherwise-clean repo (iss-107).
// An explicit --dev (or a plain install over an existing dev shim) forces an
// apply-as-update on an otherwise-clean repo, the same way an explicit value
// override does (iss-107): the requested install mode differs from what is on
// disk, so there is work to do even with zero gaps.
modeForced := modeWouldChange(opts, det)

if len(actionable(det.Gaps)) == 0 &&
!(!opts.Yes && pinAdoptable(det.Gaps)) &&
!overridesWouldChange(abs, opts.ValueOverrides) {
!overridesWouldChange(abs, opts.ValueOverrides) &&
!modeForced {
return InstallResult{Status: "already_up_to_date"}, nil
}

Expand All @@ -73,6 +80,8 @@ func Install(cwd string, opts InstallOptions, p Prompter) (InstallResult, error)
prompter: p,
gapPresent: gapIDSet(det.Gaps),
autoYes: opts.Yes,
devMode: opts.Dev,
modeForced: modeForced,
}

// Ordered apply steps.
Expand Down Expand Up @@ -119,6 +128,8 @@ type applyCtx struct {
writes []string
changes []string // human-readable value changes an explicit override forced
autoYes bool // --yes: every category auto-approved without interaction
devMode bool // --dev: install the track-latest shim instead of the symlink
modeForced bool // the requested install mode differs from the on-disk state

visibilityForced bool // an explicit --visibility override overwrote a valid value
docsTargetForced bool // an explicit --docs-target override overwrote a valid value
Expand Down Expand Up @@ -593,29 +604,100 @@ func markerFilesDropped(from, to string) []string {
return dropped
}

// stepSymlink installs the owned PATH symlink. It refuses to clobber a foreign
// binary. Default: yes for private, no for public.
// stepSymlink installs the PATH entry: an owned symlink to the pinned binary
// (default), or the track-latest dev shim under --dev. It runs on a fresh install
// (the symlink.missing gap) or when a mode switch was forced on an already-present
// owned entry (apply-as-update, iss-107). It refuses to clobber a foreign binary.
func (a *applyCtx) stepSymlink() {
if !a.approved[ConfigChange] || !a.has("symlink.missing") {
if a.det.pluginRoot == "" {
return
}
if a.det.pluginRoot == "" {
gapDriven := a.approved[ConfigChange] && a.has("symlink.missing")
if !gapDriven && !a.modeForced {
return
}
target := binTarget()
source := pluginBinaryPath(a.det.pluginRoot)
// Refuse to clobber anything already present.
if _, err := os.Lstat(target); err == nil {
kind := classifyBinTarget(target, a.det.pluginRoot)
if kind == binTargetForeign {
return // never clobber something we do not own
}
if a.devMode {
a.installDevShim(target, kind)
return
}
a.installPinnedSymlink(target, kind)
}

// installDevShim writes the track-latest shim, replacing an owned pinned symlink
// if one is there. An existing dev shim is left as-is (idempotent).
func (a *applyCtx) installDevShim(target string, kind binTargetKind) {
if kind == binTargetDevShim {
return
}
if kind == binTargetOwnedSymlink {
if err := os.Remove(target); err != nil {
return
}
a.echoChange("install_mode", "pinned", "dev")
}
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return
}
content := renderDevShim(a.det.pluginRoot, pluginBinaryPath(a.det.pluginRoot))
// Atomic no-follow write (rename over the target), matching the rest of the
// store: a symlink pre-planted at the leaf is replaced, never written through,
// and the executable bit is set via fchmod on the temp descriptor.
if err := fsutil.WriteFileAtomic(target, []byte(content), 0o755); err != nil {
return
}
a.note(target)
}

// installPinnedSymlink writes the owned symlink to the pinned binary, replacing a
// dev shim if one is there. An existing owned symlink is left as-is (idempotent).
func (a *applyCtx) installPinnedSymlink(target string, kind binTargetKind) {
if kind == binTargetOwnedSymlink {
return
}
if kind == binTargetDevShim {
if err := os.Remove(target); err != nil {
return
}
a.echoChange("install_mode", "dev", "pinned")
}
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return
}
source := pluginBinaryPath(a.det.pluginRoot)
if err := os.Symlink(source, target); err == nil {
a.note(target)
}
}

// modeWouldChange reports whether the requested install mode differs from the
// on-disk PATH target, so an otherwise up-to-date repo still has work to do. A
// foreign occupant is never touched, so it never counts as a change.
func modeWouldChange(opts InstallOptions, det DetectionResult) bool {
if det.pluginRoot == "" {
return false
}
kind := classifyBinTarget(binTarget(), det.pluginRoot)
if kind == binTargetForeign {
return false
}
if opts.Dev {
// Only a switch away from the pinned symlink is a forced change. A missing
// entry is NOT forced: it flows through the symlink.missing gap so a declined
// ConfigChange approval is honoured (a fresh --dev must not bypass consent the
// way a plain install cannot). An existing dev shim is already correct.
return kind == binTargetOwnedSymlink
}
// Plain install: only a switch away from an existing dev shim is a forced
// change; a missing entry is handled by the symlink.missing gap, and an owned
// symlink is already correct.
return kind == binTargetDevShim
}

// stepRules writes the per-repo .abcd/rules.json override skeleton when absent.
// It is deliberately the empty-domains skeleton, NOT a copy of the bundled
// defaults: the default domains live once in the abcd binary (itd-3), and this
Expand Down Expand Up @@ -683,7 +765,17 @@ func Uninstall(cwd string) (UninstallReceipt, error) {
case lerr != nil:
receipt.Symlink.Note = "absent"
case fi.Mode()&os.ModeSymlink == 0:
receipt.Symlink.Note = "not a symlink; left untouched"
// A regular file: our own dev shim (remove it — it is ours), else foreign.
if isDevShimFile(target) {
if err := os.Remove(target); err == nil {
receipt.Symlink.Removed = true
receipt.Symlink.Note = "removed dev shim"
} else {
receipt.Symlink.Note = "remove failed"
}
} else {
receipt.Symlink.Note = "not a symlink; left untouched"
}
case !ok:
receipt.Symlink.Note = "plugin root unresolved; left untouched"
default:
Expand Down Expand Up @@ -751,6 +843,9 @@ func Status(cwd string) (string, error) {
if det.RootSHA != "" {
fmt.Fprintf(&b, "root sha: %s\n", shortSHA(det.RootSHA))
}
if mode, _ := det.Signals["install_mode"].(string); mode != "" {
fmt.Fprintf(&b, "install: %s\n", mode)
}
act := actionable(det.Gaps)
switch det.FolderKind {
case UnmanagedFolder:
Expand Down
27 changes: 27 additions & 0 deletions internal/core/ahoy/detect.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,10 @@ func Detect(cwd string) (DetectionResult, error) {
pluginStatus = "resolved"
}

// The PATH-target install mode is a machine-scope fact independent of folder
// kind, surfaced so status reports "dev (tip build)" honestly (never invisible).
signals["install_mode"] = detectInstallMode(pluginRoot, pluginOK)

res := DetectionResult{
FolderKind: kind,
RootSHA: identity.RootSHA,
Expand Down Expand Up @@ -377,6 +381,11 @@ func detectPathSymlink(pluginRoot string, pluginOK bool) []Gap {
return nil
}
if fi.Mode()&modeSymlink == 0 {
if isDevShimFile(target) {
// Our own track-latest dev shim (abcd ahoy install --dev) — a valid
// install, not a foreign occupant. Surfaced via the install_mode signal.
return nil
}
return []Gap{{
ID: "symlink.foreign", Category: ConfigChange, Scope: "machine",
Title: "non-symlink at " + target, Detail: "A regular file occupies the PATH symlink target.",
Expand All @@ -397,6 +406,24 @@ func detectPathSymlink(pluginRoot string, pluginOK bool) []Gap {
}}
}

// detectInstallMode reports the current PATH-target install mode: "dev (tip
// build)" when the track-latest shim occupies it, "pinned" when our owned symlink
// does, and "" when the target is absent, foreign, or the plugin root is
// unresolved (nothing to attribute a mode to).
func detectInstallMode(pluginRoot string, pluginOK bool) string {
if !pluginOK {
return ""
}
switch classifyBinTarget(binTarget(), pluginRoot) {
case binTargetDevShim:
return "dev (tip build)"
case binTargetOwnedSymlink:
return "pinned"
default:
return ""
}
}

func detectHookManifest(pluginRoot string, pluginOK bool) []Gap {
if !pluginOK {
return nil
Expand Down
Loading