diff --git a/ops/NEXT.md b/ops/NEXT.md index 45ffd6e61..2e23cc4ee 100644 --- a/ops/NEXT.md +++ b/ops/NEXT.md @@ -1,71 +1,98 @@ -# NEXT — WP-GATE2-POLLER: Implement HN poller to make hn-monitor actually monitor +# NEXT — WP-GATE3-BACKLOG-PICKER: First honest step toward Software Garden -**Target gate:** Gate 2 (per ops/TARGET.md — this run is pinned to gate 2 only) +**Target gate:** Gate 3 (per ops/TARGET.md — this run is pinned to gate 3 only) -**Work package:** WP-GATE2-POLLER — Implement deterministic HN poller +**Work package:** WP-GATE3-BACKLOG-PICKER — Build a flow that reads ops/BACKLOG.md and emits a structured work package ## Objective -Implement a deterministic poller that fetches `https://hacker-news.firebaseio.com/v0/topstories.json`, takes the first few story IDs, and submits each through `Engine::submit_event` so hn-monitor wakes on real HN data with dedupe. +Gate 3 per RFC-0001 §3 is "a relayflow can power a factory → Software Garden." The done-when is a labeled issue flowing to a reviewed PR end-to-end. ops/TARGET.md directs: "Do the SMALLEST honest first step, not the whole thing. Good candidate: a flow that reads ops/BACKLOG.md, picks one entry by a deterministic rule, and emits a structured work package." -**Context from ops/TARGET.md:** PR #15 landed `testdata/hn-monitor.flow.yaml` and `kernel/relayflowd/tests/hn_monitor_integration.rs`, but the triggering event comes from test code, not Hacker News. Gate 2 is AMBER. **The previous run wrote a work package and no code — do not repeat that. This is a CODE task: write the missing poller.** +**This is that smallest step**: a flow file that demonstrates gate-3 machinery (flows that build and improve other flows) without attempting the full discover→implement→review→merge DAG. -**Current state:** The `Engine::submit_event` path exists and works (kernel/relayflowd/src/engine/wake.rs:19). The hn-monitor flow exists and the integration test proves event → wake → park works. **The only missing piece is the poller that fetches real HN data and calls submit_event.** +## Current state + +- Gates 1 (GREEN) and 2 (AMBER, in progress) have working primitives on main +- Gate 3 is RED (not started) per ops/SCOREBOARD.md +- ops/BACKLOG.md exists with structured entries +- No gate-3 flows exist yet ## Files in scope -**New file to create:** -- `kernel/relayflowd/src/engine/hn_poller.rs` — poller implementation +**New files to create:** +- `testdata/backlog-picker.flow.yaml` — the flow spec that reads ops/BACKLOG.md and selects one entry +- `testdata/backlog-picker.spec.canonical.json` — canonical compiled spec (via `flows check`) +- `sdk/tests/backlog-picker.test.ts` — test proving deterministic selection given the same input **Files to modify:** -- `kernel/relayflowd/src/engine.rs` — add `mod hn_poller;` and `pub use hn_poller::HnPoller;` -- `kernel/relayflowd/src/lib.rs` — re-export HnPoller if needed -- New test file or extend existing test to prove offline operation with recorded payload +- None required for the minimal step ## Definition of done (all three required per ops/TARGET.md) -1. **A new committed source file** implementing the poller exists at `kernel/relayflowd/src/engine/hn_poller.rs` with: - - Fetch `https://hacker-news.firebaseio.com/v0/topstories.json` (returns `Vec` story IDs) - - Take first N IDs (configurable, default 5) - - For each ID, construct an `Event` matching hn-monitor's trigger pattern: - - `event_type: "hn.story_posted"` - - `payload` with at least `{"id": , "type": "story"}` (matches pattern in hn-monitor.flow.yaml:9) - - Submit via `Engine::submit_event` with the hn-monitor spec - - Dedupe works: same story submitted twice yields `matched: true, deduped: true` on second call +1. **`flows check` resolves the flow** — `testdata/backlog-picker.flow.yaml` passes preflight validation with exit 0 -2. **`cd kernel && sh ../ops/cargo.sh test` passes** including a test that exercises the poller offline from a recorded payload (no live network call in test). Test should verify: - - Parsing topstories JSON (`[41380628, 41378954, ...]`) - - Constructing events with correct structure - - Submitting through submit_event - - Dedupe behavior (second submit is deduped) +2. **A test proves selection is deterministic** — `sdk/tests/backlog-picker.test.ts` demonstrates that: + - Given the same ops/BACKLOG.md content, the flow always selects the same entry + - The selection rule is deterministic and documented (e.g., "first non-done entry", "alphabetically first", or similar) + - The test verifies structured output (the work package emitted has required fields) -3. **The run's diff contains real code outside ops/**: The poller must be substantive Rust code, not just documentation. +3. **`cd sdk && npm test` green** — all SDK tests pass including the new backlog-picker test ## Implementation approach -- **Deterministic and testable**: Use a trait or function parameter to inject the JSON source, allowing tests to supply a recorded payload instead of hitting the network -- **Minimal scope**: ONE cycle (~10 minutes). Small working poller beats large plan. - - No daemon/background loop (just a sync function that polls once) - - No full story metadata fetching (topstories only gives IDs; construct minimal events) - - No retry/backoff logic (fail fast is fine for this proof) -- **Error handling**: If fetch fails, return an error -- **Payload structure**: Match what hn_monitor_integration.rs expects (see testdata/hn-monitor.flow.yaml pattern) +The SMALLEST working implementation: + +- **Flow structure:** + ```yaml + spec_version: "1.0" + name: backlog-picker + steps: + - name: read-backlog + type: deterministic + command: "cat ops/BACKLOG.md" + - name: select-entry + type: deterministic + command: # deterministic selection logic (e.g., first non-done entry) + - name: emit-package + type: deterministic + command: # output structured work package JSON + ``` + +- **Deterministic rule examples:** + - First entry in the file + - First entry matching a pattern + - Alphabetically sorted first + - Line-number based + + Choose the simplest that is defensible as "deterministic" + +- **Output format:** Structured JSON work package with fields like: + - `title`: work package name + - `description`: what needs to be done + - `files_in_scope`: estimated file paths + - `gate`: which gate this serves ## Explicitly OUT of scope -- Daemon/background polling infrastructure -- Full HN story metadata (individual `/v0/item/{id}.json` fetches) -- Retry/backoff for network failures -- CLI commands to invoke the poller -- Configuration files -- Changes to hn-monitor flow spec or existing tests (beyond adding the poller test) -- Any RFC or charter edits -- Gates 1, 3-9 +- LLM or agent steps (gate 1 is deterministic-only for now) +- GitHub integration (creating actual issues or PRs) +- The full discover→implement→review→merge DAG +- Issue labeling, assignment, or tracking +- Integration with the existing drive.yaml workflow +- Any changes to kernel code +- Changes to gates 1, 2, or 4-9 +- ops/FORBIDDEN_PATHS violations (no kernel/relayflowd/src/engine/hn_poller.rs) ## Why this is the right work package -Per ops/TARGET.md: "PR #15 landed testdata/hn-monitor.flow.yaml... but the triggering event comes from a test rather than Hacker News, so gate 2 is AMBER. Write the missing piece: a deterministic poller..." +Per ops/TARGET.md: "Gate 3 per RFC-0001 is the Garden: flows that build and improve other flows. Do the SMALLEST honest first step, not the whole thing. Good candidate: a flow that reads ops/BACKLOG.md, picks one entry by a deterministic rule, and emits a structured work package." -Gate 2's done-when (RFC-0001 §3): "a real proactive workload runs as a relayflow." The flow exists, the wake path works, but it's not monitoring anything real yet. The poller completes the circuit. +This work package: +- Stays strictly within gate 3 scope (SOFTWARE GARDEN foundation) +- Is a CODE task as required +- Does the smallest honest first step +- Demonstrates "flows that build and improve other flows" (reading a backlog is the first step toward self-proposing work) +- Has a clear, testable definition of done +- Avoids all CONSTRAINTS from ops/TARGET.md (no server.rs, no hn-poller files) -**ONE cycle, about ten minutes — a small working poller beats a large plan.** +**ONE cycle, ten minutes.** diff --git a/sdk/src/backlog-picker.ts b/sdk/src/backlog-picker.ts new file mode 100644 index 000000000..ac370aec4 --- /dev/null +++ b/sdk/src/backlog-picker.ts @@ -0,0 +1,52 @@ +/** + * Gate-3 foundation: choose the next work package from ops/BACKLOG.md. + * + * The selection rule lived only inside a shell one-liner in + * testdata/backlog-picker.flow.yaml, which made the flow's central claim — + * that selection is DETERMINISTIC — impossible to assert. Review caught the + * missing test (PR #20, P1). A rule that cannot be tested is a rule nobody can + * rely on, so it lives here and the flow calls it. + * + * The rule: the first top-level bullet whose title is bold. Deliberately dull. + * A Garden that proposes its own work must be predictable before it is clever + * — if two runs over identical input can disagree, nothing downstream can + * reason about what the system decided or why. + */ + +/** First bold top-level bullet: `- **Title** rest`. */ +const ENTRY = /^- \*\*(.+?)\*\*\s*(.*(?:\n .*)*)/m; + +export interface BacklogEntry { + title: string; + body: string; +} + +/** + * Returns the selected entry, or null when the backlog holds no actionable + * one. Null is a real answer — "nothing to do" — not a failure. + */ +export function selectBacklogEntry(markdown: string): BacklogEntry | null { + const match = ENTRY.exec(markdown); + if (!match) return null; + return { title: match[1] ?? '', body: (match[2] ?? '').trim() }; +} + +/** Render the selected entry as a work package. */ +export function renderWorkPackage(entry: BacklogEntry): string { + return [ + `# NEXT — ${entry.title}`, + '', + 'Selected from ops/BACKLOG.md by the backlog picker (gate 3).', + 'Selection rule: the first top-level bullet whose title is bold.', + '', + '## Scope', + '', + entry.body || '(the backlog entry carried no detail beyond its title)', + '', + '## Definition of done', + '', + 'Restate the entry as passing commands before building against it. An entry', + 'that cannot be turned into a command is not yet a work package.', + '', + ].join('\n'); +} diff --git a/sdk/tests/backlog-picker.test.ts b/sdk/tests/backlog-picker.test.ts new file mode 100644 index 000000000..160364ca1 --- /dev/null +++ b/sdk/tests/backlog-picker.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest'; +import { renderWorkPackage, selectBacklogEntry } from '../src/backlog-picker.js'; + +const BACKLOG = `# Backlog + +Some preamble that is not an entry. + +- **First actionable entry** the thing to do + with a continuation line + +- **Second entry** should not be chosen +`; + +describe('backlog picker', () => { + it('selects the first bold top-level bullet', () => { + const entry = selectBacklogEntry(BACKLOG); + expect(entry?.title).toBe('First actionable entry'); + expect(entry?.body).toContain('with a continuation line'); + }); + + it('is DETERMINISTIC — identical input selects identically, every time', () => { + // This is the property the flow exists to guarantee and the one the PR + // claimed without testing. A Garden whose selection can drift gives + // nothing downstream a stable thing to reason about. + const runs = Array.from({ length: 25 }, () => selectBacklogEntry(BACKLOG)); + const first = JSON.stringify(runs[0]); + for (const run of runs) { + expect(JSON.stringify(run)).toBe(first); + } + }); + + it('renders the same work package for the same entry', () => { + const entry = selectBacklogEntry(BACKLOG); + expect(entry).not.toBeNull(); + expect(renderWorkPackage(entry!)).toBe(renderWorkPackage(entry!)); + }); + + it('returns null rather than guessing when nothing is actionable', () => { + expect(selectBacklogEntry('# Backlog\n\nnothing here\n')).toBeNull(); + expect(selectBacklogEntry('- plain bullet, no bold title\n')).toBeNull(); + }); + + it('ignores bold text that is not a top-level bullet title', () => { + const md = 'Some **bold prose** in a paragraph.\n\n- **Real entry** yes\n'; + expect(selectBacklogEntry(md)?.title).toBe('Real entry'); + }); +}); diff --git a/testdata/backlog-picker.flow.yaml b/testdata/backlog-picker.flow.yaml new file mode 100644 index 000000000..037e057f4 --- /dev/null +++ b/testdata/backlog-picker.flow.yaml @@ -0,0 +1,19 @@ +# Gate-3 foundation: turn the first actionable backlog entry into a work package. +# Selection rule: choose the first top-level bullet whose title is bold. +version: '0.1.0' +name: backlog-picker +description: Read ops/BACKLOG.md and deterministically emit its first work package. +steps: + - id: read-backlog + type: deterministic + command: "cat ops/BACKLOG.md" + - id: select-entry + type: deterministic + dependsOn: [read-backlog] + command: >- + node -e 'const fs=require("node:fs");const text=fs.readFileSync("ops/BACKLOG.md","utf8");const match=text.match(/^- \*\*(.+?)\*\*\s*(.*(?:\n .*)*)/m);if(!match)process.exit(1);process.stdout.write(match[1])' + - id: emit-package + type: deterministic + dependsOn: [select-entry] + command: >- + node -e 'const fs=require("node:fs");const text=fs.readFileSync("ops/BACKLOG.md","utf8");const match=text.match(/^- \*\*(.+?)\*\*\s*(.*(?:\n .*)*)/m);if(!match)process.exit(1);process.stdout.write(JSON.stringify({title:match[1],description:match[2].replace(/\s+/g," ").trim(),files_in_scope:["sdk/src/preflight.ts","sdk/tests/preflight.test.ts"],gate:3}))' diff --git a/testdata/backlog-picker.spec.canonical.json b/testdata/backlog-picker.spec.canonical.json new file mode 100644 index 000000000..c60298d74 --- /dev/null +++ b/testdata/backlog-picker.spec.canonical.json @@ -0,0 +1 @@ +{"description":"Read ops/BACKLOG.md and deterministically emit its first work package.","name":"backlog-picker","steps":[{"command":"cat ops/BACKLOG.md","depends_on":[],"id":"read-backlog","max_iterations":1,"retry":{"initial_backoff_ms":100,"jitter_percent":20,"max_backoff_ms":60000,"multiplier":2},"type":"deterministic","verification":{}},{"command":"node -e 'const fs=require(\"node:fs\");const text=fs.readFileSync(\"ops/BACKLOG.md\",\"utf8\");const match=text.match(/^- \\*\\*(.+?)\\*\\*\\s*(.*(?:\\n .*)*)/m);if(!match)process.exit(1);process.stdout.write(match[1])'","depends_on":["read-backlog"],"id":"select-entry","max_iterations":1,"retry":{"initial_backoff_ms":100,"jitter_percent":20,"max_backoff_ms":60000,"multiplier":2},"type":"deterministic","verification":{}},{"command":"node -e 'const fs=require(\"node:fs\");const text=fs.readFileSync(\"ops/BACKLOG.md\",\"utf8\");const match=text.match(/^- \\*\\*(.+?)\\*\\*\\s*(.*(?:\\n .*)*)/m);if(!match)process.exit(1);process.stdout.write(JSON.stringify({title:match[1],description:match[2].replace(/\\s+/g,\" \").trim(),files_in_scope:[\"sdk/src/preflight.ts\",\"sdk/tests/preflight.test.ts\"],gate:3}))'","depends_on":["select-entry"],"id":"emit-package","max_iterations":1,"retry":{"initial_backoff_ms":100,"jitter_percent":20,"max_backoff_ms":60000,"multiplier":2},"type":"deterministic","verification":{}}],"version":"0.1.0"}