Files
boshu2__agentops/cli/internal/workflowsapp/link.go
Bo 238964fdd2 feat(cli): workflows are canonical product artifacts — workflows/ + ao workflows link (#945)
Workflows get the skills treatment (operator decision): canonical source
in the product tree, installed by a product verb, Claude-only labeled as
such.

**What moves:** all seven Claude workflow scripts + README migrate from
force-added exceptions inside the gitignored `.claude/` to a tracked
top-level `workflows/` (sibling of `skills/`) — the four existing
conveyors plus `audit-dimensions`, `verify-fixes`, `implement-wave`:
three thin, args-parameterized orchestration conveyors extracted from
this session's hand-rolled waves, contract-reviewed, and smoke-proven
through the real Workflow runtime (the smoke caught two contract gaps
static review could not: an `export default` wrapper the runtime never
invokes, and args arriving as a JSON string — both fixed, string-args
tolerance now built in).

**New verb:** `ao workflows link` / `unlink` mirror `ao skills link`
semantics — dry-run `--json`, refuse to replace real files or foreign
links, unlink only checkout-owned links — targeting the project-local
`.claude/workflows/` where Claude Code resolves named workflows
(`--into` overrides). Checkout identity reuses the skillsapp marker
discipline, fail-closed. Claude-only runtime adapter, same doctrine as
the Codex-only `skills-codex/`.

**Legacy surfaces repointed:** `install-workflows.sh` (user-global $HOME
installer), `check-workflow-drift.sh` + gate comment,
`check-bdd-foundry-markers.sh`; spine allowlist + YAML-probe excuse +
go-cli.md spine region gain the workflows group; COMMANDS.md,
cli-surface projections, and surface-count fixture regenerated; new
tests carry per-command git-env scrubbing (test-isolation ratchet back
at baseline).

**Built BY the workflow being canonized** — `implement-wave`
orchestrated its own canonization: two disjoint-ownership lanes plus a
seam-checking verifier that ran the real binary's link → resolve →
unlink cycle in the live tree (both lanes RESOLVED). The lanes correctly
*refused* to self-approve their command into the spine invariants and
handed integration three flagged edits instead.

**Expected local gate note:** `workflow.install-drift` correctly FAILS
on machines whose user-global `~/.claude/workflows` links still point at
the old location — that is the transition it exists to catch. CI stays
green (absent→skip). **Post-merge operator step:** `cd ~/dev/agentops &&
git pull && bash scripts/install-workflows.sh`.

**Verified:** full suite 63/63 pkgs; golangci-lint clean; `gate check
--full` over this range = 66/67 with only the documented install-drift
environment finding; workflows smoke-run evidence in session logs.
2026-07-20 19:39:16 -04:00

127 lines
4.5 KiB
Go

// practices: [design-by-contract, code-complete]
package workflowsapp
import (
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strings"
)
// LinkResult summarizes a `workflows link` sync into the single Claude target:
// which workflow scripts received a fresh symlink, which were already present,
// and which names collide with a real file or a foreign symlink that we refuse
// to touch. It mirrors skillsapp.LinkResult minus the multi-runtime fan-out —
// workflows are a Claude-only adapter, so there is exactly one destination.
type LinkResult struct {
Source string `json:"source"`
Dest string `json:"dest"`
DryRun bool `json:"dry_run"`
Linked []string `json:"linked"`
Present []string `json:"present"`
Conflicts []string `json:"conflicts"`
}
// LinkWorkflows scans srcDir for workflow scripts (regular *.js files) and
// ensures each has a symlink at destDir/<name> pointing at the absolute
// source. It is idempotent and non-destructive: an existing correct symlink is
// left as Present, and a name already owned by a REAL file or by a symlink
// pointing elsewhere is reported as a Conflict and never clobbered —
// resolving ownership of an operator-owned path is operator judgment, not
// ours. When dryRun is true nothing is written (destDir is not even created),
// but the would-be links are still reported under Linked.
func LinkWorkflows(srcDir, destDir string, dryRun bool) (LinkResult, error) {
res := LinkResult{Dest: destDir, DryRun: dryRun}
// Fail-closed on an unresolved source: an empty srcDir would let
// filepath.Abs("") resolve to the CURRENT directory and silently scan/link
// whatever happens to sit there. Refuse rather than guess (mirror of the
// age-u031 guard on skills link).
if strings.TrimSpace(srcDir) == "" {
return res, fmt.Errorf("workflows source dir is empty — cannot resolve the checkout workflows/ tree (run from inside the agentops repo)")
}
absSrc, err := filepath.Abs(srcDir)
if err != nil {
return res, fmt.Errorf("resolve workflows dir %s: %w", srcDir, err)
}
res.Source = absSrc
entries, err := os.ReadDir(absSrc)
if err != nil {
return res, fmt.Errorf("read workflows dir %s: %w", absSrc, err)
}
for _, e := range entries {
name := e.Name()
if e.IsDir() || !strings.HasSuffix(name, ".js") {
continue // only workflow scripts are linked; README and subdirs stay home
}
src := filepath.Join(absSrc, name)
tgt := filepath.Join(destDir, name)
info, lerr := os.Lstat(tgt)
switch {
case lerr == nil && info.Mode()&os.ModeSymlink != 0:
dst, readErr := os.Readlink(tgt)
if readErr != nil {
return res, fmt.Errorf("read link %s: %w", tgt, readErr)
}
if !filepath.IsAbs(dst) {
dst = filepath.Join(destDir, dst)
}
if filepath.Clean(dst) == filepath.Clean(src) {
res.Present = append(res.Present, name)
} else {
// A symlink pointing elsewhere is not healthy merely because it
// is a symlink. Report it without replacing an operator-owned path.
res.Conflicts = append(res.Conflicts, name)
}
case lerr == nil:
res.Conflicts = append(res.Conflicts, name) // real file — operator-owned
case os.IsNotExist(lerr):
res.Linked = append(res.Linked, name) // the missing link
if !dryRun {
if err := os.MkdirAll(destDir, 0o755); err != nil {
return res, fmt.Errorf("create dest dir %s: %w", destDir, err)
}
if err := os.Symlink(src, tgt); err != nil {
return res, fmt.Errorf("link %s -> %s: %w", tgt, src, err)
}
}
default:
return res, fmt.Errorf("stat %s: %w", tgt, lerr)
}
}
sort.Strings(res.Linked)
sort.Strings(res.Present)
sort.Strings(res.Conflicts)
return res, nil
}
// RenderLinkResult prints the human link summary for the single Claude target.
func RenderLinkResult(out io.Writer, res LinkResult) {
fmt.Fprintf(out, "Workflows link (Claude-only) → %s\n", res.Dest)
if res.DryRun {
fmt.Fprintf(out, " missing (dry-run, not linked): %d\n", len(res.Linked))
} else {
fmt.Fprintf(out, " linked: %d\n", len(res.Linked))
}
fmt.Fprintf(out, " present: %d\n", len(res.Present))
fmt.Fprintf(out, " conflicts: %d\n", len(res.Conflicts))
for _, n := range res.Linked {
mark := "+"
if res.DryRun {
mark = "?"
}
fmt.Fprintf(out, " %s %s\n", mark, n)
}
for _, n := range res.Conflicts {
fmt.Fprintf(out, " ! %s (real file or foreign symlink — not clobbered; resolve ownership explicitly)\n", n)
}
if len(res.Linked) == 0 && len(res.Conflicts) == 0 {
fmt.Fprintln(out, " all workflow scripts already linked.")
}
}