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
3 changes: 2 additions & 1 deletion docs/install.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,8 @@ forgecell deliver MOLECULE_ID --lab DIR --base BRANCH -- FILE... # preview, th
`loop --max-concurrent N` sets how many Molecules it advances at once (default 1) and `--once` makes a
single pass; `loop --watch` repeats a pass every `--interval` (default 5m), and
`--learn` also runs the pending learn requests after each pass, like
`learn --pending`. `retire MOLECULE_ID --reason TEXT` retires a stale Molecule and
`learn --pending`. After its first pass, a watch prints only what changed, plus a
one-line summary. `retire MOLECULE_ID --reason TEXT` retires a stale Molecule and
keeps every record; `recover MOLECULE_ID --plan DIGEST --confirm-stopped` recovers an
interrupted one; `checks` reads a pull request's required CI checks at an exact commit for
a Molecule's delivery and exits nonzero unless they passed.
Expand Down
2 changes: 1 addition & 1 deletion lab/internal/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ func Run(ctx context.Context, args []string, in io.Reader, out, stderr io.Writer
fail := func(err error) int { fmt.Fprintln(stderr, "forgecell:", err); return 1 }
harness.SetRunningVersion(version)
if len(args) == 0 || args[0] == "--help" || args[0] == "help" {
fmt.Fprintln(out, "Forgecell Lab — Go migration preview\n\n"+demoUsage+"\ninit [--lab DIR] [--harness ID | --rebind ID] [--meta-harness ID | --meta-command JSON_ARGV] [--harness-timeout-ms MS] [--max-timeout-ms MS] [--json] [--review ID | --approve ID | --dismiss ID]\ninit --model ID [--role analysis|coding|meta|review] [--lab DIR] [--json]\ninit --refresh-baseline [--lab DIR] [--json]\ninit --enable-review [--lab DIR] [--json]\ndoctor [--lab DIR] [--harness ID] [--json] [--verbose]\ndoctor --clean [--yes] [--lab DIR]\nrun <issue> [--lab DIR] [--formula ID] [--base COMMIT] [--target BRANCH] [--approve DIGEST] [--json]\namend <molecule> [--add-scope PATH --reason TEXT]... [--add-acceptance TEXT]... [--note TEXT] [--continuation checks-only] [--accept-existing-tree TREE] [--run] [--lab DIR]\namend <molecule> --accept-protected-failures [--add-acceptance TEXT]... [--note TEXT] [--lab DIR] (declare the tests that failed by design in the latest protected regression run as expectedProtectedDeltas; continues checks-only; takes no --run: review the printed list, then approve the digest with run <issue> --approve DIGEST)\namend <molecule> --parent DIGEST --plan FILE [--accept-existing-tree TREE] [--run] [--lab DIR]\namend <molecule> --parent DIGEST --rebase-formula [--lab DIR]\nrecover <molecule> --plan DIGEST --confirm-stopped [--lab DIR]\nretire <molecule> --reason TEXT [--lab DIR] (mark a stale Molecule retired: one-line reason of at most 200 characters; every record is kept, the loop never resumes it and may start a fresh Molecule for its Issue)\nloop [--lab DIR] [--select oldest|label:L|milestone:M] [--max-concurrent N] [--once]\nloop [--lab DIR] --watch [--interval DURATION] [--learn]\nledger <molecule> [--lab DIR]\nissues --repo OWNER/REPO [--page N --page-size N | --select ISSUE_REF] [--lab DIR] [--json]\nlearn <molecule...> [--lab DIR] [--json]\nlearn --pending [--lab DIR] [--json]\nsuggestion <id> [--lab DIR] [--approve | --dismiss | --link-evaluation DIR | --baseline-ledger ID --candidate-ledger ID --comparability-basis TEXT] [--evaluation-parent DIR] [--json]\nrollback\ndev install [--from CHECKOUT] [--activate] [--allow-dirty]\nevaluate --inputs PLAN --out NEW_DIRECTORY [--source REPO] [--approve DIGEST]\nevaluate --cohort FILE --out DIRECTORY [--source REPO] [--approve DIGEST] [--parallel N] [--recheck]\nevaluate replay --repo OWNER/REPO --pr N [--pr M ...] --source CHECKOUT --out NEW_DIRECTORY\ndeliver <molecule> --lab DIR --base BRANCH [--approve DIGEST --title TITLE] -- FILE...\nchecks --lab DIR --molecule ID --repo OWNER/REPO --pr NUMBER --commit SHA --required NAME [--required NAME]\n"+findingsUsage+"\nlabs [--json]\n"+pluginHelp+"\nui [--lab DIR] [--lab-name NAME] [--port N] [--no-open] [--demo]\n--version\n--protocol\n\nThe default Lab is keyed by the checkout's GitHub origin (OWNER/REPO), not its path; --lab DIR always wins and --lab-name NAME selects a named Lab for the repository on any command that takes a Lab. labs lists every Lab, read-only.\n\ndev install is developer-only: it builds the checkout's lab/cmd/forgecell with the local Go toolchain and then runs the result, so the checkout is trusted code. Without --activate the build is only staged under releases/ and current is unchanged. A tree with uncommitted, untracked or ignored Go source changes is refused unless --allow-dirty is given. --protocol prints the adapter protocol version.\n\nui serves the Lab UI on 127.0.0.1 behind a per-launch token and opens it in the browser unless --no-open is given. Its writes go through the engine and are bound to the digest you saw; it never merges or deploys. With no ready Lab it serves Set up your Lab; --demo shows synthetic sample data. It stops on SIGINT or SIGTERM.\n\n"+demoHelp+"\n\nRuns analyze a ticket before coding and wait for exact scope approval. Required checks run independently before final review.\n\nloop starts intake for eligible open Issues of the Formula's repository, oldest first, and approves a scope gate only where the Formula declares a policy that approves that exact plan. It stops each Molecule at any human gate, open question or failure and reports why; it never delivers, merges or approves a human gate. --once makes a single pass. --watch repeats single passes every --interval (default 5m, at least 1m) until SIGINT or SIGTERM, which stop it between passes after in-flight Molecules finish; it cannot be combined with --once. --learn, which is off by default, runs the pending learn requests after each pass like learn --pending; suggestions stay pending for your decision. For an interrupted Molecule whose owner is gone, the loop saves a recovery proposal (the exact forgecell recover command) under the Lab's recovery-proposals directory and shows it in its report and in doctor; it never runs recover or amend.\n\nTicket readiness is a development preview; existing Formulas require an explicitly reviewed scope gate.")
fmt.Fprintln(out, "Forgecell Lab — Go migration preview\n\n"+demoUsage+"\ninit [--lab DIR] [--harness ID | --rebind ID] [--meta-harness ID | --meta-command JSON_ARGV] [--harness-timeout-ms MS] [--max-timeout-ms MS] [--json] [--review ID | --approve ID | --dismiss ID]\ninit --model ID [--role analysis|coding|meta|review] [--lab DIR] [--json]\ninit --refresh-baseline [--lab DIR] [--json]\ninit --enable-review [--lab DIR] [--json]\ndoctor [--lab DIR] [--harness ID] [--json] [--verbose]\ndoctor --clean [--yes] [--lab DIR]\nrun <issue> [--lab DIR] [--formula ID] [--base COMMIT] [--target BRANCH] [--approve DIGEST] [--json]\namend <molecule> [--add-scope PATH --reason TEXT]... [--add-acceptance TEXT]... [--note TEXT] [--continuation checks-only] [--accept-existing-tree TREE] [--run] [--lab DIR]\namend <molecule> --accept-protected-failures [--add-acceptance TEXT]... [--note TEXT] [--lab DIR] (declare the tests that failed by design in the latest protected regression run as expectedProtectedDeltas; continues checks-only; takes no --run: review the printed list, then approve the digest with run <issue> --approve DIGEST)\namend <molecule> --parent DIGEST --plan FILE [--accept-existing-tree TREE] [--run] [--lab DIR]\namend <molecule> --parent DIGEST --rebase-formula [--lab DIR]\nrecover <molecule> --plan DIGEST --confirm-stopped [--lab DIR]\nretire <molecule> --reason TEXT [--lab DIR] (mark a stale Molecule retired: one-line reason of at most 200 characters; every record is kept, the loop never resumes it and may start a fresh Molecule for its Issue)\nloop [--lab DIR] [--select oldest|label:L|milestone:M] [--max-concurrent N] [--once]\nloop [--lab DIR] --watch [--interval DURATION] [--learn]\nledger <molecule> [--lab DIR]\nissues --repo OWNER/REPO [--page N --page-size N | --select ISSUE_REF] [--lab DIR] [--json]\nlearn <molecule...> [--lab DIR] [--json]\nlearn --pending [--lab DIR] [--json]\nsuggestion <id> [--lab DIR] [--approve | --dismiss | --link-evaluation DIR | --baseline-ledger ID --candidate-ledger ID --comparability-basis TEXT] [--evaluation-parent DIR] [--json]\nrollback\ndev install [--from CHECKOUT] [--activate] [--allow-dirty]\nevaluate --inputs PLAN --out NEW_DIRECTORY [--source REPO] [--approve DIGEST]\nevaluate --cohort FILE --out DIRECTORY [--source REPO] [--approve DIGEST] [--parallel N] [--recheck]\nevaluate replay --repo OWNER/REPO --pr N [--pr M ...] --source CHECKOUT --out NEW_DIRECTORY\ndeliver <molecule> --lab DIR --base BRANCH [--approve DIGEST --title TITLE] -- FILE...\nchecks --lab DIR --molecule ID --repo OWNER/REPO --pr NUMBER --commit SHA --required NAME [--required NAME]\n"+findingsUsage+"\nlabs [--json]\n"+pluginHelp+"\nui [--lab DIR] [--lab-name NAME] [--port N] [--no-open] [--demo]\n--version\n--protocol\n\nThe default Lab is keyed by the checkout's GitHub origin (OWNER/REPO), not its path; --lab DIR always wins and --lab-name NAME selects a named Lab for the repository on any command that takes a Lab. labs lists every Lab, read-only.\n\ndev install is developer-only: it builds the checkout's lab/cmd/forgecell with the local Go toolchain and then runs the result, so the checkout is trusted code. Without --activate the build is only staged under releases/ and current is unchanged. A tree with uncommitted, untracked or ignored Go source changes is refused unless --allow-dirty is given. --protocol prints the adapter protocol version.\n\nui serves the Lab UI on 127.0.0.1 behind a per-launch token and opens it in the browser unless --no-open is given. Its writes go through the engine and are bound to the digest you saw; it never merges or deploys. With no ready Lab it serves Set up your Lab; --demo shows synthetic sample data. It stops on SIGINT or SIGTERM.\n\n"+demoHelp+"\n\nRuns analyze a ticket before coding and wait for exact scope approval. Required checks run independently before final review.\n\nloop starts intake for eligible open Issues of the Formula's repository, oldest first, and approves a scope gate only where the Formula declares a policy that approves that exact plan. It stops each Molecule at any human gate, open question or failure and reports why; it never delivers, merges or approves a human gate. --once makes a single pass. --watch repeats single passes every --interval (default 5m, at least 1m) until SIGINT or SIGTERM, which stop it between passes after in-flight Molecules finish; it cannot be combined with --once. After its first pass, a watch prints only what changed, plus a one-line summary. --learn, which is off by default, runs the pending learn requests after each pass like learn --pending; suggestions stay pending for your decision. For an interrupted Molecule whose owner is gone, the loop saves a recovery proposal (the exact forgecell recover command) under the Lab's recovery-proposals directory and shows it in its report and in doctor; it never runs recover or amend.\n\nTicket readiness is a development preview; existing Formulas require an explicitly reviewed scope gate.")
return 0
}
if args[0] == "--version" || args[0] == "-v" {
Expand Down
176 changes: 147 additions & 29 deletions lab/internal/cli/loop.go
Original file line number Diff line number Diff line change
Expand Up @@ -155,21 +155,39 @@ func watchLoop(ctx context.Context, options molecule.LoopOptions, interval time.
options.Work = context.WithoutCancel(ctx)
code, failures := 0, 0
errored := false
// last is what the last completed pass rendered; until one completed, a pass
// prints its full report. A skip repeated with the same reason prints one line.
var last *watchView
skipped, lastSkip := false, ""
for n := 1; ctx.Err() == nil; n++ {
fmt.Fprintf(out, "Watch pass %d\n", n)
// Below a soft floor the pass is skipped, with its reason in the pass report;
// the next pass checks again after the interval.
if guard := options.Template.Resources; guard != nil {
if level, reason := guard.Check(); level != resources.OK {
fmt.Fprintf(out, "Pass skipped: %s\n", reason)
if skipped && reason == lastSkip {
fmt.Fprintf(out, "Watch pass %d: skipped (same reason)\n", n)
} else {
fmt.Fprintf(out, "Watch pass %d\nPass skipped: %s\n", n, reason)
}
skipped, lastSkip = true, reason
if !watchWait(ctx, interval) {
break
}
continue
}
}
skipped = false
report, err := runLoop(ctx, options)
renderLoop(out, report, options.Select, options.MaxConcurrent)
view := newWatchView(report)
if err != nil || last == nil {
fmt.Fprintf(out, "Watch pass %d\n", n)
renderLoop(out, report, options.Select, options.MaxConcurrent)
} else {
renderWatchChanges(out, n, *last, view, report)
}
if err == nil {
last = &view
}
renderLearnNotices(out, stderr, lab)
wait := interval
if err != nil {
Expand Down Expand Up @@ -290,32 +308,7 @@ func renderLoop(out io.Writer, report molecule.LoopReport, selector intake.Selec
fmt.Fprintf(out, "Warning: %s\n", note)
}
for _, item := range pass.Items {
id := item.MoleculeID
if id == "" {
id = "none"
}
actions := ""
if item.Started {
actions += " · started intake"
}
if item.Approved {
actions += " · approved by policy"
}
if item.Key != "" {
fmt.Fprintf(out, "%s %s · %s · Molecule %s%s\n %s\n", item.Repository, flatten(item.Key), item.State, id, actions, item.Reason)
} else {
fmt.Fprintf(out, "%s#%d · %s · Molecule %s%s\n %s\n", item.Repository, item.Issue, item.State, id, actions, item.Reason)
}
if p := item.Proposal; p != nil {
saved := "saved"
if p.Existing {
saved = "already proposed"
}
fmt.Fprintf(out, " Recovery proposal (%s; the loop never runs it): %s\n %s\n", saved, flatten(p.Path), p.Command)
for _, step := range p.Followup {
fmt.Fprintf(out, " then: %s\n", step)
}
}
io.WriteString(out, loopItemText(item))
}
if pass.Stopped != "" {
fmt.Fprintf(out, "Pass %d stopped: %s\n", n+1, flatten(pass.Stopped))
Expand All @@ -325,3 +318,128 @@ func renderLoop(out io.Writer, report molecule.LoopReport, selector intake.Selec
fmt.Fprintf(out, "%d Issue(s) made no progress in the final pass and need attention\n", n)
}
}

// loopItemText is the lines renderLoop prints for one item: the item line, its
// reason, and any recovery proposal with its follow-ups.
func loopItemText(item molecule.LoopItem) string {
var b strings.Builder
id := item.MoleculeID
if id == "" {
id = "none"
}
actions := ""
if item.Started {
actions += " · started intake"
}
if item.Approved {
actions += " · approved by policy"
}
if item.Key != "" {
fmt.Fprintf(&b, "%s %s · %s · Molecule %s%s\n %s\n", item.Repository, flatten(item.Key), item.State, id, actions, item.Reason)
} else {
fmt.Fprintf(&b, "%s#%d · %s · Molecule %s%s\n %s\n", item.Repository, item.Issue, item.State, id, actions, item.Reason)
}
if p := item.Proposal; p != nil {
saved := "saved"
if p.Existing {
saved = "already proposed"
}
fmt.Fprintf(&b, " Recovery proposal (%s; the loop never runs it): %s\n %s\n", saved, flatten(p.Path), p.Command)
for _, step := range p.Followup {
fmt.Fprintf(&b, " then: %s\n", step)
}
}
return b.String()
}

// watchView is what one completed watch pass rendered, so the next pass prints
// only what changed. It holds no more than that pass's report already did.
type watchView struct {
order []string
labels map[string]string
items map[string]string
warnings []string
stops []watchStop
}

type watchStop struct{ reason, line string }

func newWatchView(report molecule.LoopReport) watchView {
v := watchView{labels: map[string]string{}, items: map[string]string{}}
if report.Repository == "" {
// renderLoop prints nothing for such a report.
return v
}
for n, pass := range report.Passes {
for _, note := range pass.Notes {
v.warnings = append(v.warnings, fmt.Sprintf("Warning: %s\n", note))
}
for _, item := range pass.Items {
// The kind prefix keeps a keyed item from colliding with a numbered one.
key, label := fmt.Sprintf("n\x00%s\x00%d", item.Repository, item.Issue), fmt.Sprintf("%s#%d", item.Repository, item.Issue)
if item.Key != "" {
key, label = "k\x00"+item.Repository+"\x00"+item.Key, item.Repository+" "+flatten(item.Key)
}
if _, seen := v.items[key]; !seen {
v.order = append(v.order, key)
}
v.labels[key], v.items[key] = label, loopItemText(item)
}
if pass.Stopped != "" {
reason := flatten(pass.Stopped)
v.stops = append(v.stops, watchStop{reason, fmt.Sprintf("Pass %d stopped: %s\n", n+1, reason)})
}
}
return v
}

// renderWatchChanges prints a later watch pass: one summary line in place of the
// pass and loop headers, then each new warning, each new or changed item, one
// line per item no longer eligible, and each new stop line. Every printed group
// counts as one change.
func renderWatchChanges(out io.Writer, n int, prev, cur watchView, report molecule.LoopReport) {
var body strings.Builder
changes := 0
seenWarning := map[string]bool{}
for _, w := range prev.warnings {
seenWarning[w] = true
}
for _, w := range cur.warnings {
if !seenWarning[w] {
seenWarning[w] = true
body.WriteString(w)
changes++
}
}
for _, key := range cur.order {
if text := cur.items[key]; prev.items[key] != text {
body.WriteString(text)
changes++
}
}
for _, key := range prev.order {
if _, ok := cur.items[key]; !ok {
fmt.Fprintf(&body, "%s · no longer eligible\n", prev.labels[key])
changes++
}
}
seenStop := map[string]bool{}
for _, s := range prev.stops {
seenStop[s.reason] = true
}
for _, s := range cur.stops {
if !seenStop[s.reason] {
seenStop[s.reason] = true
body.WriteString(s.line)
changes++
}
}
summary := "no change"
if changes > 0 {
summary = fmt.Sprintf("%d change(s)", changes)
}
if j := report.NonProgress(); j > 0 {
summary += fmt.Sprintf(" · %d Issue(s) need attention", j)
}
fmt.Fprintf(out, "Watch pass %d: %s\n%s", n, summary, body.String())
}
Loading
Loading