Research efforts can be ideas, and at most three are active (issue 1970, ADR 0629)

The roadmap lives in the knowledge base: an idea is listed, never served as in force; propose refuses a further active effort.

Claude-Session: https://claude.ai/code/session_01JG73KG7AfSdh6FP8VrBqCn
This commit is contained in:
2026-11-04 14:24:44 +01:00
parent 7fe569ef40
commit b0a275c126
8 changed files with 163 additions and 11 deletions
@@ -11,6 +11,7 @@ import (
"errors"
"fmt"
"regexp"
"sort"
"strconv"
"strings"
"sync"
@@ -53,8 +54,14 @@ type Settings struct {
// ReservedFor is how long a reserved number waits for its proposal before it is withdrawn, as a duration
// such as 168h; DefaultReservedFor when empty.
ReservedFor string `json:"reserved-for,omitempty"`
// ResearchActiveAtMost is the most research efforts that may be active at once (ADR 0629); DefaultResearchActiveAtMost
// when absent or 0.
ResearchActiveAtMost int `json:"research-active-at-most,omitempty"`
}
// DefaultResearchActiveAtMost is how many research efforts may be active at once unless the settings say otherwise.
const DefaultResearchActiveAtMost = 3
// DefaultReservedFor is how long a reservation waits for its proposal unless the settings say otherwise.
const DefaultReservedFor = 7 * 24 * time.Hour
@@ -248,7 +255,7 @@ type ProposeAsk struct {
NeedsOperator bool
// Brief is what the operator's ask shows (novox/hq issue 639).
Brief *Brief
// Status is a research effort's: active, graduated or abandoned.
// Status is a research effort's: active, graduated, abandoned or idea.
Status string
// Insight, for an accepted record, is what stood before: the proposal is a progressive insight.
Insight string
@@ -348,7 +355,7 @@ func (k *KB) Propose(caller string, a ProposeAsk) (*Proposed, error) {
}
// An active research effort is still being researched: it takes new versions freely, each reviewed as a
// proposal while the served version stays (novox/hq issue 844). Every other accepted record needs an insight.
revision := a.Kind == KindResearch && held.Current.Status == StatusActive && strings.TrimSpace(a.Insight) == ""
revision := a.Kind == KindResearch && (held.Current.Status == StatusActive || held.Current.Status == StatusIdea) && strings.TrimSpace(a.Insight) == ""
if strings.TrimSpace(a.Insight) == "" && !revision {
return nil, fmt.Errorf("%s is %s: it changes only by a new record, or by a progressive insight, a correction of fact "+
"that leaves the decision standing (give insight: what stood before). Nothing was proposed", key, held.Current.Status)
@@ -390,6 +397,11 @@ func (k *KB) Propose(caller string, a ProposeAsk) (*Proposed, error) {
if !current && a.Kind == KindResearch && r.Effort == "" {
r.Effort = held.Current.Status
}
if a.Kind == KindResearch && r.Effort == StatusActive {
if why := k.activeLimitRefusal(ctx, key); why != "" {
refusals = append(refusals, Refusal{Rule: "research-active-at-most", Says: why})
}
}
// A new version of an active effort is new text: the dated rules hold it from the day it is proposed, while the
// record keeps its own date (novox/hq issue 844).
day := r.Date
@@ -434,6 +446,36 @@ func (k *KB) Propose(caller string, a ProposeAsk) (*Proposed, error) {
return &Proposed{Record: key.String(), Version: n, Status: StatusProposed, Says: says + "."}, nil
}
// activeLimitRefusal (ADR 0629 §4, a check of the record set beside the one-record rules of ADR 0290 §4) says why a research effort may not be active now: the limit of efforts other than this one
// already active (accepted at active, or proposed at active and waiting for review) is reached (ADR 0629). Empty
// when it may.
func (k *KB) activeLimitRefusal(ctx context.Context, key Key) string {
limit := k.settings().ResearchActiveAtMost
if limit <= 0 {
limit = DefaultResearchActiveAtMost
}
all, err := k.Store.All(ctx)
if err != nil {
return fmt.Sprintf("the active efforts cannot be counted (%v)", err)
}
var names []string
for _, e := range all {
if e.Current == nil || e.Current.Kind != KindResearch || e.Current.Key() == key {
continue
}
w := e.Working()
if e.Current.Status == StatusActive || (w != nil && w.Effort == StatusActive && (w.Status == StatusProposed || e.Pending != nil)) {
names = append(names, fmt.Sprintf("%s (%s)", e.Current.Key(), e.Current.Title))
}
}
if len(names) < limit {
return ""
}
sort.Strings(names)
return fmt.Sprintf("at most %d research efforts are active and %d already are: %s. Park one as an idea first, or propose this one as an idea",
limit, len(names), strings.Join(names, "; "))
}
// ---- acceptance ------------------------------------------------------------------------------------------
// accept makes a reviewed version the one served: accepted, its clauses in force, every clause and record it