Skip to content

Commit c03eadd

Browse files
Sync Boatstack from Intelligence Flow Labs @ b37bfc311634 (#151)
Co-authored-by: operator-stack-publisher[bot] <operator-stack-publisher[bot]@users.noreply.github.com>
1 parent 6685003 commit c03eadd

12 files changed

Lines changed: 782 additions & 21 deletions

File tree

CONTRIBUTING.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
# Contributing
44

5-
Boatstack is a generated content distribution. Propose changes to workflow semantics, templates, evidence rules, or generated presentation in [Intelligence Flow](https://github.com/operatorstack/intelligence-flow/tree/c550ef95f440e8b463ae0d436953e10ad91e1ab6/labs/12-product-engineering-loop).
5+
Boatstack is a generated content distribution. Propose changes to workflow semantics, templates, evidence rules, or generated presentation in [Intelligence Flow](https://github.com/operatorstack/intelligence-flow/tree/b37bfc311634ab20082ce1d768f0c953530d72cc/labs/12-product-engineering-loop).
66

77
The Boatstack repository receives product/runtime changes through a generated pull request. Review the PR's `UPSTREAM.json`, tests, adapter diff, and context-size change; do not hand-edit generated output on `main`. `.github/workflows` is the exception: it is Boatstack's executable control plane, excluded from scheduled projection and changed only through a separate manually reviewed Boatstack PR.
88

UPSTREAM.json

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
},
1313
"files": {
1414
".gitignore": "a7079e923a776f14f1bb3a6aa0a11a133a8e1dfb35af020f327623357b7e3957",
15-
"CONTRIBUTING.md": "370fe1191864a3a3b4acd5ca4bc22807f70da794743f376f992a140c534e0619",
15+
"CONTRIBUTING.md": "dde868caa9c38db8ceaa264ee034fdd33eb413e57b570845dfeb5d44c1e9d8d7",
1616
"README.md": "3ce3e95e511089b44e946a44b8d5f4f81d019ece5336db65b2cab1f9dc4d4dad",
1717
"assets/boatstack-journey.svg": "e465befc50c8ce30f3e07e8fd97012931beeb053392c8fbf38ad645023b3cc63",
1818
"assets/boatstack-mark.svg": "be1f984da1bfa69fa5d1f986d8343d21f7e20921b71db888c928b4d2e54b09b5",
@@ -129,6 +129,12 @@
129129
"boatstack/internal/deliverycontrol/trajectorylog.go": "a1da7e7252b33f63f232c101de683b4a515e80243801caf69fda53d24233e42f",
130130
"boatstack/internal/deliverycontrol/trajectorylog_test.go": "227dd6ed9ce181d517a37b67ef4d64dd93779a533eae804798ab54de35c7f13e",
131131
"boatstack/internal/deliverycontrol/transition.go": "b43abb0e99d29697b27b0bb8ee2e2f5f31f3471a2983f25d18ae3564ee246775",
132+
"boatstack/internal/retromine/adapters.go": "7736ebe7fe200dc6aca2799dab964a49031e4f840a049dc872f0bb62053a1ed8",
133+
"boatstack/internal/retromine/cluster.go": "ddb6b3506ac192ab37f0341e57437ddf365207c42163da423a06bbfd856758f5",
134+
"boatstack/internal/retromine/event.go": "4d41cbf37209d01bc5cc083d144f835950700aa7f6e39d0661b525b5e045227a",
135+
"boatstack/internal/retromine/retromine_conformance_test.go": "33883893db1e455901d9c0e93767c39b475fae728aa9ee9dd925a2c21e5b0ec4",
136+
"boatstack/internal/retromine/testdata/session-alpha.jsonl": "45ed8fa691af9db3f957e87175dbbeffd8fdd2f001324e86a5fd68e29ffbe244",
137+
"boatstack/internal/retromine/testdata/session-beta.txt": "8e85e6a4442e3c892166df97ec879d998b3a47bdad5ef2bf785674b3149c36c2",
132138
"boatstack/migrate.go": "eaf589e2b266238068e42c6d78e01dc040266d28e342cb24f09e33e8541749b3",
133139
"boatstack/migrate_effect_grade.go": "bccb58e770001aa9554d8e7f151663d907f152508a61118f56fd90465ba6f32e",
134140
"boatstack/migrate_effect_grade_test.go": "fea1d1057bc6d8eaf015e377864a3adab29ef5731f597fe0a38b96fa80355d14",
@@ -215,10 +221,10 @@
215221
"docs/benchmark-corpus-audit.md": "f2d206fe8579a514f9da82b2c96c19b343ac004be67617e1bd34f0f8e0e5e6c6",
216222
"docs/benchmark-submission-audit.md": "9518abdd17690729c6423f87cab20418ed47b0915b5faa44b9ef975e9e9c3b79",
217223
"docs/configuration.md": "221f979506a3a9de357e5277f1329c345bf175346ec8dfc8fdd1212fb100dea1",
218-
"docs/evidence-engineered-coding.md": "42c0efc8f5947b4b21f7f99ea8b86a303025bf3760b8fe17caf07ed47acfe609",
224+
"docs/evidence-engineered-coding.md": "2a2b905e044d89cad2855f10c45261e980b0507c60e275165c8de8ed2a663f42",
219225
"docs/generated-files.md": "437791765b0a4015032ae21d1a6618563cad92b7402819e4f963bf5ae16284a3",
220226
"docs/getting-started.md": "51c2823f21e35140d31e6d5083dc4b89fddd24721ac6acc474154a4da53ee9f8",
221-
"docs/public-claims.json": "b47e678c1a830a0bd6ceea4fdc3fae7b55794099770df61f6819b9802212f561",
227+
"docs/public-claims.json": "6f78d9f8b0ce76079f23a43d84008045dc7e51e5004a2f3e394784e44a3b752e",
222228
"docs/public-surface.md": "713f7a050b5f339cf948299103ef3800417dccfecf2cc1a4166397ea6f978907",
223229
"docs/research-and-design.md": "8d78678108f0a6c924e1ff9b32c0f81aae9d1f779e0082843b6f99ad993ae2b6",
224230
"docs/safety.md": "7b9b5c515d36e683767ec8d3d9d6d119ac93650b2f629d351deadd4c600ed6a6",
@@ -232,7 +238,7 @@
232238
"labs/diagram-json/compiled/evidence.md": "1ba1c989ade070a8ef9a508fbd788d100d7292f2dbacbb2bce895468019f619d",
233239
"labs/diagram-json/compiled/tasks.json": "88f60851abf79d851e9fccc754ff3040034ae595306bc87d64784c19eb403e71",
234240
"labs/diagram-json/compiled/test-matrix.json": "424657ff505768e50fa113801fd8363364a18269d5297480907a993d44063a39",
235-
"labs/diagram-json/plan.lock.json": "77ff19d1baf7a9872a2e0e052a1b02fdc804607eff4106bc677cf70c995e970e",
241+
"labs/diagram-json/plan.lock.json": "2c2182f842a9ab47814c685ad0762bb63da4859fc27f583a25a32597e97d7b79",
236242
"labs/diagram-json/plan.md": "3cc4f533b8d69386deff16b3a594a3ba09d4c0c3db636cccd8c4380084ce6a51",
237243
"labs/diagram-json/questions.md": "74733b015002c8a6777c558e7e997fa48c94850b9bd39054fe9366c97ecf728d",
238244
"labs/diagram-json/request.md": "0808fc41c36779c404f4a3a121167da6e76cac56df526e70f9ed6d3e0d4c02ed",
@@ -370,12 +376,13 @@
370376
"release-notes/2026-07-28-operator-frontier-next-actor.md": "7e769625a2beb8a204d2d79158c18bdac350656de5c55998988a8a5aba2c319a",
371377
"release-notes/2026-07-28-post-publish-prescriptions.md": "a0b728df569bdba40e3b6107179b35873bad00e1ed1c2574e7eecf438b6962f4",
372378
"release-notes/2026-07-28-pr-phase-observation.md": "8c5615013eb88ce9561d30897e47f6fa967e0157c4c245f0c35a1f31e4e132e2",
373-
"release-notes/2026-07-28-protected-native-auto-merge.md": "67dc76a6e7ce51034a0eadc541ba7a8946cfcabe5433cedc25db55321dfb8b62"
379+
"release-notes/2026-07-28-protected-native-auto-merge.md": "67dc76a6e7ce51034a0eadc541ba7a8946cfcabe5433cedc25db55321dfb8b62",
380+
"release-notes/2026-07-28-retromine-recurrence-detector.md": "27790993a02e73f3a2700dce7340d044aeb0e52f785ded68271ff6673add9e25"
374381
},
375382
"generator": "operatorstack/intelligence-flow:boatstack-distribution",
376383
"schema_version": 1,
377384
"source": {
378-
"commit": "c550ef95f440e8b463ae0d436953e10ad91e1ab6",
385+
"commit": "b37bfc311634ab20082ce1d768f0c953530d72cc",
379386
"path": "labs/12-product-engineering-loop",
380387
"repository": "operatorstack/intelligence-flow"
381388
}
Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
package retromine
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"io"
7+
"strings"
8+
)
9+
10+
// Adapters perform the lossy projection from one host's transcript format
11+
// into neutral events. Lossiness is one-directional by design: an adapter may
12+
// SKIP entries it does not understand (host formats grow shapes constantly),
13+
// but a line that fails to parse as the format at all is a typed error —
14+
// mis-parsing must never silently become "no recurrence found".
15+
// control-law: retro-derivation-is-offline-and-deterministic
16+
17+
// Format names for ParseTranscript.
18+
const (
19+
FormatNeutral = "events"
20+
FormatClaudeCode = "claudecode"
21+
FormatPlaintext = "plaintext"
22+
)
23+
24+
// ParseTranscript dispatches to the named adapter, or sniffs the format from
25+
// content when format is empty: a JSON object line with a "role" field is the
26+
// neutral format, one with "type"/"message" is a Claude Code session line,
27+
// anything else is plain text.
28+
func ParseTranscript(format, source string, content []byte) ([]Event, error) {
29+
if format == "" {
30+
format = sniffFormat(content)
31+
}
32+
switch format {
33+
case FormatNeutral:
34+
return ParseNeutralEvents(source, strings.NewReader(string(content)))
35+
case FormatClaudeCode:
36+
return ParseClaudeCodeSession(source, strings.NewReader(string(content)))
37+
case FormatPlaintext:
38+
return ParsePlaintextTranscript(source, strings.NewReader(string(content)))
39+
default:
40+
return nil, fmt.Errorf("unknown transcript format %q (supported: events, claudecode, plaintext)", format)
41+
}
42+
}
43+
44+
func sniffFormat(content []byte) string {
45+
for _, line := range strings.Split(string(content), "\n") {
46+
line = strings.TrimSpace(line)
47+
if line == "" {
48+
continue
49+
}
50+
if !strings.HasPrefix(line, "{") {
51+
return FormatPlaintext
52+
}
53+
var probe map[string]json.RawMessage
54+
if err := json.Unmarshal([]byte(line), &probe); err != nil {
55+
return FormatPlaintext
56+
}
57+
if _, ok := probe["role"]; ok {
58+
return FormatNeutral
59+
}
60+
return FormatClaudeCode
61+
}
62+
return FormatPlaintext
63+
}
64+
65+
// claudeCodeLine is the subset of a Claude Code session JSONL entry the
66+
// projection needs. Message content is either a plain string or an array of
67+
// typed blocks; only text blocks carry conversational text, and tool_result
68+
// blocks mark tool output.
69+
type claudeCodeLine struct {
70+
Type string `json:"type"`
71+
SessionID string `json:"sessionId"`
72+
Timestamp string `json:"timestamp"`
73+
Message struct {
74+
Role string `json:"role"`
75+
Content json.RawMessage `json:"content"`
76+
} `json:"message"`
77+
}
78+
79+
// ParseClaudeCodeSession projects a Claude Code session JSONL stream into
80+
// neutral events. Entries whose type is not user/assistant (summaries,
81+
// hooks, system reminders) are skipped — projection is lossy — but a line
82+
// that is not valid JSON is a typed error.
83+
func ParseClaudeCodeSession(source string, r io.Reader) ([]Event, error) {
84+
scanner := newLineScanner(r)
85+
events := []Event{}
86+
line := 0
87+
for scanner.Scan() {
88+
line++
89+
raw := strings.TrimSpace(scanner.Text())
90+
if raw == "" {
91+
continue
92+
}
93+
var entry claudeCodeLine
94+
if err := json.Unmarshal([]byte(raw), &entry); err != nil {
95+
return nil, fmt.Errorf("parse claudecode session %s line %d: %w", source, line, err)
96+
}
97+
role := ""
98+
switch entry.Type {
99+
case "user":
100+
role = RoleOperator
101+
case "assistant":
102+
role = RoleAgent
103+
default:
104+
continue
105+
}
106+
text, isToolPayload := claudeCodeText(entry.Message.Content)
107+
if isToolPayload {
108+
role = RoleTool
109+
}
110+
if strings.TrimSpace(text) == "" {
111+
continue
112+
}
113+
sessionID := entry.SessionID
114+
if sessionID == "" {
115+
sessionID = source
116+
}
117+
events = append(events, Event{
118+
Source: source, SessionID: sessionID, Timestamp: entry.Timestamp,
119+
Role: role, Text: text,
120+
})
121+
}
122+
if err := scanner.Err(); err != nil {
123+
return nil, fmt.Errorf("read claudecode session %s: %w", source, err)
124+
}
125+
return assignSessionIndexes(events), nil
126+
}
127+
128+
// claudeCodeText extracts conversational text from a message content value.
129+
// The bool reports that the content was ONLY tool payload (tool results),
130+
// which projects as RoleTool so it never counts as an operator instruction.
131+
func claudeCodeText(content json.RawMessage) (string, bool) {
132+
if len(content) == 0 {
133+
return "", false
134+
}
135+
var plain string
136+
if err := json.Unmarshal(content, &plain); err == nil {
137+
return plain, false
138+
}
139+
var blocks []struct {
140+
Type string `json:"type"`
141+
Text string `json:"text"`
142+
}
143+
if err := json.Unmarshal(content, &blocks); err != nil {
144+
return "", false
145+
}
146+
texts := []string{}
147+
sawTool := false
148+
for _, block := range blocks {
149+
switch block.Type {
150+
case "text":
151+
if strings.TrimSpace(block.Text) != "" {
152+
texts = append(texts, block.Text)
153+
}
154+
case "tool_result", "tool_use":
155+
sawTool = true
156+
}
157+
}
158+
if len(texts) == 0 {
159+
return "", sawTool
160+
}
161+
return strings.Join(texts, "\n"), false
162+
}
163+
164+
// plaintextPrefixes maps a line prefix to a role for the plain-text adapter.
165+
// Order matters: first match wins. Unprefixed text continues the current
166+
// speaker's turn; before any prefix appears, text defaults to the operator —
167+
// fail-open into the INPUT only (the worst a misclassified line can do is
168+
// create one more proposal for a human to reject; it can never act).
169+
var plaintextPrefixes = []struct {
170+
prefix string
171+
role string
172+
}{
173+
{"user:", RoleOperator},
174+
{"operator:", RoleOperator},
175+
{"h:", RoleOperator},
176+
{">", RoleOperator},
177+
{"assistant:", RoleAgent},
178+
{"agent:", RoleAgent},
179+
{"a:", RoleAgent},
180+
{"tool:", RoleTool},
181+
}
182+
183+
// ParsePlaintextTranscript projects a prefix-annotated plain-text transcript
184+
// (`User: …` / `Agent: …`) into neutral events. Consecutive lines of one
185+
// speaker merge into one event; the whole file is one session identified by
186+
// its source name.
187+
func ParsePlaintextTranscript(source string, r io.Reader) ([]Event, error) {
188+
scanner := newLineScanner(r)
189+
events := []Event{}
190+
currentRole := RoleOperator
191+
var current []string
192+
flush := func() {
193+
text := strings.TrimSpace(strings.Join(current, "\n"))
194+
current = nil
195+
if text == "" {
196+
return
197+
}
198+
events = append(events, Event{Source: source, SessionID: source, Role: currentRole, Text: text})
199+
}
200+
for scanner.Scan() {
201+
line := scanner.Text()
202+
trimmed := strings.TrimSpace(line)
203+
matched := false
204+
lower := strings.ToLower(trimmed)
205+
for _, candidate := range plaintextPrefixes {
206+
if strings.HasPrefix(lower, candidate.prefix) {
207+
flush()
208+
currentRole = candidate.role
209+
current = append(current, strings.TrimSpace(trimmed[len(candidate.prefix):]))
210+
matched = true
211+
break
212+
}
213+
}
214+
if !matched {
215+
current = append(current, line)
216+
}
217+
}
218+
flush()
219+
if err := scanner.Err(); err != nil {
220+
return nil, fmt.Errorf("read plaintext transcript %s: %w", source, err)
221+
}
222+
return assignSessionIndexes(events), nil
223+
}

0 commit comments

Comments
 (0)