diff --git a/ops/NEXT.md b/ops/NEXT.md index 7b172248..944480c7 100644 --- a/ops/NEXT.md +++ b/ops/NEXT.md @@ -1,49 +1,73 @@ -# NEXT — Gate 3: Build the work package consumer +# NEXT — Consumer validates files_in_scope paths exist -**Scope:** Gate 3 — close the Garden's loop. CODE task, SDK-side. The picker EMITS a work package (sdk/src/backlog-picker.ts + testdata/backlog-picker.flow.yaml, merged, four tested properties) and NOTHING consumes it — that is the missing half. Build the consumer: an SDK entrypoint taking an emitted package and turning it into something runnable, validating it has a title, a non-empty scope and a definition of done, and REFUSING with a typed reason when it does not, because a package that cannot be verified must not become work. NOTE: two previous attempts (ee5c9b3e, 06c0d6ab) did this correctly and their files were LOST before delivery by a platform fault — the build sandbox's .git points at a directory that does not exist, so writes cannot be captured. You are not duplicating live work. +## Target scope (from gate 3) -## Objective +**Scope:** Harden the Garden's loop with a case it does not yet handle. CODE task, SDK-side. + +On main now, all merged and tested: + - `sdk/src/backlog-picker.ts` — proposes a work package from ops/BACKLOG.md + - `sdk/src/work-package-consumer.ts` — judges one, refusing with a typed + reason (missing_title / missing_scope / missing_definition_of_done) + - `testdata/backlog-picker.flow.yaml` — the flow, with its canonical spec + - a test running the flow's real emit-package output through the consumer, + proving the two halves interoperate in both directions + +So propose -> judge -> accept/refuse works end to end. What it does NOT do is +survive a hostile or malformed backlog. Pick ONE of these and do it properly: + + (a) The picker reads whatever ops/BACKLOG.md contains. A malformed entry — a + bold title with no body, an unterminated backtick, a bullet nested under + another — should produce a typed refusal, never a crash and never a + half-formed package. Add the handling and the tests. -Build the SDK entrypoint that takes an emitted work package and turns it into something runnable. The consumer must validate that the package has: -- A title (non-empty string) -- A non-empty scope -- A definition of done + (b) The consumer accepts any package whose fields are present. It does not + check that files_in_scope names paths that EXIST, so a package can be + accepted while scoping files that are not there. Add that check as a new + typed refusal reason, with tests. + +**Decision:** Implementing option (b) — files_in_scope path existence validation. + +## Objective -When any of these is missing or invalid, the consumer REFUSES with a typed reason. A package that cannot be verified must not become work. +Add a new refusal reason to `work-package-consumer.ts` that rejects packages +scoping nonexistent files. A package accepted today while naming files that do +not exist (`sdk/src/does-not-exist.ts`) must be refused with a typed reason +(`nonexistent_files`). ## Files in scope -- `sdk/src/` (new consumer code) -- `sdk/tests/` (new consumer tests) -- NO changes to `kernel/` (PR #19 is open) -- NO changes to `sdk/src/demo-hn-monitor.ts` (PR #19 is open) +- `sdk/src/work-package-consumer.ts` +- `sdk/tests/work-package-consumer.test.ts` +- `sdk/src/index.ts` (if new types need export) ## Definition of done -1. **SDK code exists** that consumes an emitted work package -2. **Validation tests exist** for: - - Missing title → typed refusal - - Empty title → typed refusal - - Missing scope → typed refusal - - Empty scope → typed refusal - - Missing definition of done → typed refusal - - Empty definition of done → typed refusal - - Valid package → accepted -3. **Every new test is confirmed to FAIL against current code** with literal output pasted -4. **SDK test suite passes:** +1. **New typed refusal reason** `nonexistent_files` added to + `WorkPackageRefusalReason` type +2. **Path existence check** in `consumeWorkPackage` that refuses when + files_in_scope contains paths that do not exist on disk +3. **Tests covering the new behavior:** + - Test: refuse a package with all nonexistent files + - Test: refuse a package with a mix of existent and nonexistent files + - Test: accept a package with all existent files +4. **EVERY new test confirmed to FAIL against current code** — the literal + failing output must be quoted below when reporting BUILD_DONE +5. **Existing tests still passing** — all 8 existing work-package-consumer tests + green +6. **Full SDK suite passing:** ``` cd sdk && npm test ``` - Paste the literal output showing all tests pass, 0 failed -5. **Final verification** — as the LAST action, run: + Must show green suite with exact pass count. +7. **As the LAST action, run and paste:** ``` git status --porcelain ``` - And paste the output to make lost writes visible immediately ## Out of scope -- Kernel changes (different PR) -- Integration with hn-monitor (different PR) -- Any changes to the backlog picker itself (already merged in PRs #20, #21, #22) -- Changes to flow execution or scheduling +- Picker malformed-entry handling (option a) — that is NOT this run's target +- Checking that files are readable, only that they exist +- Recursively resolving directory globs — check the literal path only +- Any changes to kernel/, testdata/, or ops/ +- Any changes to sdk/src/demo-hn-monitor.ts diff --git a/sdk/src/work-package-consumer.ts b/sdk/src/work-package-consumer.ts index 1611a1b7..7bceac47 100644 --- a/sdk/src/work-package-consumer.ts +++ b/sdk/src/work-package-consumer.ts @@ -1,3 +1,6 @@ +import { existsSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; + /** The work-package shape emitted at the SDK boundary. */ export interface EmittedWorkPackage { title: string; @@ -10,7 +13,8 @@ export interface EmittedWorkPackage { export type WorkPackageRefusalReason = | 'missing_title' | 'missing_scope' - | 'missing_definition_of_done'; + | 'missing_definition_of_done' + | 'nonexistent_files'; export type WorkPackageConsumption = | { accepted: true; work: EmittedWorkPackage } @@ -30,9 +34,18 @@ export function consumeWorkPackage(input: unknown): WorkPackageConsumption { if (!isNonEmptyStringArray(input['definition_of_done'])) { return { accepted: false, reason: 'missing_definition_of_done' }; } + if (!input['files_in_scope'].every(pathExists)) { + return { accepted: false, reason: 'nonexistent_files' }; + } return { accepted: true, work: input as unknown as EmittedWorkPackage }; } +const repositoryRoot = new URL('../../', import.meta.url); + +function pathExists(path: string): boolean { + return existsSync(fileURLToPath(new URL(path, repositoryRoot))); +} + function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } diff --git a/sdk/tests/work-package-consumer.test.ts b/sdk/tests/work-package-consumer.test.ts index 4b8d4884..120952f2 100644 --- a/sdk/tests/work-package-consumer.test.ts +++ b/sdk/tests/work-package-consumer.test.ts @@ -55,6 +55,29 @@ describe('work package consumer', () => { it('accepts a valid package as runnable work', async () => { expect(await consume(validPackage)).toEqual({ accepted: true, work: validPackage }); }); + + it('refuses a package when all files in scope are nonexistent', async () => { + expect( + await consume({ ...validPackage, files_in_scope: ['sdk/src/does-not-exist.ts'] }), + ).toEqual({ accepted: false, reason: 'nonexistent_files' }); + }); + + it('refuses a package when some files in scope are nonexistent', async () => { + expect( + await consume({ + ...validPackage, + files_in_scope: ['sdk/src/work-package-consumer.ts', 'sdk/src/does-not-exist.ts'], + }), + ).toEqual({ accepted: false, reason: 'nonexistent_files' }); + }); + + it('accepts a package when all files in scope exist', async () => { + const input = { + ...validPackage, + files_in_scope: ['sdk/src/work-package-consumer.ts', 'sdk/tests/'], + }; + expect(await consume(input)).toEqual({ accepted: true, work: input }); + }); }); describe('the Garden join: picker output feeds the consumer', () => { @@ -83,7 +106,7 @@ describe('the Garden join: picker output feeds the consumer', () => { // An entry carrying a runnable command: that IS its definition of done. writeFileSync( join(dir, 'ops', 'BACKLOG.md'), - '# Backlog\n\n- **Actionable entry** touches `sdk/src/x.ts`, verified by `npm test --silent`\n', + '# Backlog\n\n- **Actionable entry** touches `sdk/src/work-package-consumer.ts`, verified by `npm test --silent`\n', ); run(step('read-backlog'), dir); run(step('select-entry'), dir);