devlyn-cli 2.6.1 → 2.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +10 -72
- package/CLAUDE.md +29 -12
- package/README.md +7 -5
- package/benchmark/auto-resolve/fixtures/F1-cli-trivial-flag/task.ko.txt +8 -0
- package/benchmark/auto-resolve/fixtures/retired/F34-cli-settle-inflight/NOTES.md +28 -0
- package/benchmark/auto-resolve/fixtures/retired/F34-cli-settle-inflight/RETIRED.md +15 -0
- package/benchmark/auto-resolve/fixtures/retired/F34-cli-settle-inflight/expected.json +57 -0
- package/benchmark/auto-resolve/fixtures/retired/F34-cli-settle-inflight/metadata.json +10 -0
- package/benchmark/auto-resolve/fixtures/retired/F34-cli-settle-inflight/setup.sh +50 -0
- package/benchmark/auto-resolve/fixtures/retired/F34-cli-settle-inflight/spec.md +59 -0
- package/benchmark/auto-resolve/fixtures/retired/F34-cli-settle-inflight/task.txt +9 -0
- package/benchmark/auto-resolve/fixtures/retired/F34-cli-settle-inflight/verifiers/ordered-settlement.js +88 -0
- package/benchmark/auto-resolve/fixtures/retired/F34-cli-settle-inflight/verifiers/reject-invalid.js +45 -0
- package/benchmark/auto-resolve/fixtures/retired/F35-cli-apply-journal/NOTES.md +30 -0
- package/benchmark/auto-resolve/fixtures/retired/F35-cli-apply-journal/RETIRED.md +16 -0
- package/benchmark/auto-resolve/fixtures/retired/F35-cli-apply-journal/expected.json +64 -0
- package/benchmark/auto-resolve/fixtures/retired/F35-cli-apply-journal/metadata.json +10 -0
- package/benchmark/auto-resolve/fixtures/retired/F35-cli-apply-journal/setup.sh +21 -0
- package/benchmark/auto-resolve/fixtures/retired/F35-cli-apply-journal/spec.md +58 -0
- package/benchmark/auto-resolve/fixtures/retired/F35-cli-apply-journal/task.txt +9 -0
- package/benchmark/auto-resolve/fixtures/retired/F35-cli-apply-journal/verifiers/dup-op.js +54 -0
- package/benchmark/auto-resolve/fixtures/retired/F35-cli-apply-journal/verifiers/priority-apply.js +82 -0
- package/benchmark/auto-resolve/fixtures/retired/F35-cli-apply-journal/verifiers/rollback.js +77 -0
- package/benchmark/auto-resolve/fixtures/retired/F36-cli-session-admission/NOTES.md +99 -0
- package/benchmark/auto-resolve/fixtures/retired/F36-cli-session-admission/RETIRED.md +18 -0
- package/benchmark/auto-resolve/fixtures/retired/F36-cli-session-admission/expected.json +64 -0
- package/benchmark/auto-resolve/fixtures/retired/F36-cli-session-admission/metadata.json +10 -0
- package/benchmark/auto-resolve/fixtures/retired/F36-cli-session-admission/setup.sh +4 -0
- package/benchmark/auto-resolve/fixtures/retired/F36-cli-session-admission/spec.md +61 -0
- package/benchmark/auto-resolve/fixtures/retired/F36-cli-session-admission/task.txt +9 -0
- package/benchmark/auto-resolve/fixtures/retired/F36-cli-session-admission/verifiers/correctness-small.js +135 -0
- package/benchmark/auto-resolve/fixtures/retired/F36-cli-session-admission/verifiers/scale.js +100 -0
- package/benchmark/auto-resolve/fixtures/retired/F36-cli-session-admission/verifiers/validation.js +75 -0
- package/benchmark/auto-resolve/fixtures/retired/F37-cli-rule-lookup/NOTES.md +93 -0
- package/benchmark/auto-resolve/fixtures/retired/F37-cli-rule-lookup/RETIRED.md +16 -0
- package/benchmark/auto-resolve/fixtures/retired/F37-cli-rule-lookup/expected.json +65 -0
- package/benchmark/auto-resolve/fixtures/retired/F37-cli-rule-lookup/metadata.json +10 -0
- package/benchmark/auto-resolve/fixtures/retired/F37-cli-rule-lookup/setup.sh +18 -0
- package/benchmark/auto-resolve/fixtures/retired/F37-cli-rule-lookup/spec.md +66 -0
- package/benchmark/auto-resolve/fixtures/retired/F37-cli-rule-lookup/task.txt +9 -0
- package/benchmark/auto-resolve/fixtures/retired/F37-cli-rule-lookup/verifiers/correctness-small.js +84 -0
- package/benchmark/auto-resolve/fixtures/retired/F37-cli-rule-lookup/verifiers/scale.js +184 -0
- package/benchmark/auto-resolve/fixtures/retired/F37-cli-rule-lookup/verifiers/validation.js +74 -0
- package/benchmark/auto-resolve/scripts/pair-rejected-fixtures.sh +12 -0
- package/benchmark/auto-resolve/scripts/run-fixture.sh +39 -1
- package/benchmark/auto-resolve/scripts/test-lint-fixtures.sh +1 -1
- package/bin/devlyn.js +71 -83
- package/config/skills/_shared/adapters/README.md +7 -0
- package/config/skills/_shared/adapters/claude.md +49 -2
- package/config/skills/_shared/adapters/omp.md +17 -0
- package/config/skills/_shared/archive_run.py +8 -2
- package/config/skills/_shared/codex-config.md +22 -6
- package/config/skills/_shared/engine-doctor.sh +107 -0
- package/config/skills/_shared/engine-preflight.md +18 -11
- package/config/skills/_shared/expected.schema.json +26 -0
- package/config/skills/_shared/pair-plan-schema.md +2 -2
- package/config/skills/_shared/runtime-principles.md +1 -1
- package/config/skills/_shared/spec-verify-check.py +773 -486
- package/config/skills/_shared/state-phase-write.py +374 -0
- package/config/skills/_shared/verify-merge-findings.py +177 -61
- package/config/skills/devlyn:engines/SKILL.md +47 -0
- package/config/skills/devlyn:ideate/SKILL.md +22 -3
- package/config/skills/devlyn:ideate/references/elicitation.md +2 -2
- package/config/skills/devlyn:ideate/references/from-spec-mode.md +2 -2
- package/config/skills/devlyn:ideate/references/project-mode.md +1 -1
- package/config/skills/devlyn:ideate/references/spec-template.md +15 -4
- package/config/skills/devlyn:queue/SKILL.md +26 -0
- package/config/skills/devlyn:resolve/SKILL.md +55 -25
- package/config/skills/devlyn:resolve/references/free-form-mode.md +1 -1
- package/config/skills/devlyn:resolve/references/phases/build-gate.md +2 -2
- package/config/skills/devlyn:resolve/references/phases/cleanup.md +1 -1
- package/config/skills/devlyn:resolve/references/phases/implement.md +1 -1
- package/config/skills/devlyn:resolve/references/phases/plan.md +3 -2
- package/config/skills/devlyn:resolve/references/phases/probe-derive.md +8 -0
- package/config/skills/devlyn:resolve/references/phases/verify.md +18 -9
- package/config/skills/devlyn:resolve/references/state-schema.md +11 -7
- package/optional-skills/devlyn:pencil-pull/SKILL.md +5 -0
- package/optional-skills/devlyn:pencil-push/SKILL.md +5 -0
- package/package.json +1 -2
- package/scripts/lint-skills.sh +55 -46
- package/agents-config/evaluator.md +0 -64
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# F37 setup — seed a small, deliberately unsorted rule-revisions file.
|
|
3
|
+
set -e
|
|
4
|
+
|
|
5
|
+
mkdir -p data
|
|
6
|
+
|
|
7
|
+
cat > data/rule-revisions.json <<'JSON'
|
|
8
|
+
[
|
|
9
|
+
{ "id": "rev-202", "categoryId": "cat-2", "effectiveAt": 2500, "discountPct": 25, "minPrice": 45 },
|
|
10
|
+
{ "id": "rev-101", "categoryId": "cat-1", "effectiveAt": 1000, "discountPct": 10, "minPrice": 50 },
|
|
11
|
+
{ "id": "rev-301", "categoryId": "cat-3", "effectiveAt": 500, "discountPct": 0, "minPrice": 100 },
|
|
12
|
+
{ "id": "rev-103", "categoryId": "cat-1", "effectiveAt": 3000, "discountPct": 15, "minPrice": 55 },
|
|
13
|
+
{ "id": "rev-201", "categoryId": "cat-2", "effectiveAt": 1500, "discountPct": 5, "minPrice": 40 },
|
|
14
|
+
{ "id": "rev-102", "categoryId": "cat-1", "effectiveAt": 2000, "discountPct": 20, "minPrice": 60 }
|
|
15
|
+
]
|
|
16
|
+
JSON
|
|
17
|
+
|
|
18
|
+
exit 0
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
---
|
|
2
|
+
id: "F37-cli-rule-lookup"
|
|
3
|
+
title: "Price events against the rule in effect at their own timestamp"
|
|
4
|
+
status: planned
|
|
5
|
+
complexity: high
|
|
6
|
+
depends-on: []
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# F37 Price events against the rule in effect at their own timestamp
|
|
10
|
+
|
|
11
|
+
## Context
|
|
12
|
+
|
|
13
|
+
`bench-cli` gets a `price-events` command that prices a batch of purchase
|
|
14
|
+
events using the pricing rule revision that was actually in effect for that
|
|
15
|
+
event's category at the event's own timestamp — not the newest rule, and
|
|
16
|
+
not the same rule for every event in a category. Rule revisions arrive from
|
|
17
|
+
a seeded, unsorted file with many revisions per category. Downstream
|
|
18
|
+
billing tools parse stdout as JSON, and they replay very large event
|
|
19
|
+
batches, so the lookup must stay exact and the command must finish in
|
|
20
|
+
reasonable time.
|
|
21
|
+
|
|
22
|
+
## Requirements
|
|
23
|
+
|
|
24
|
+
- [ ] `bench-cli price-events --input <path>` reads JSON shaped as `{ "events": [{ "id": string, "categoryId": string, "timestamp": number, "basePrice": number }] }`.
|
|
25
|
+
- [ ] Pricing rule revisions come from `data/rule-revisions.json`, shaped as an array of `{ "id": string, "categoryId": string, "effectiveAt": number, "discountPct": number, "minPrice": number }`. Revisions in that file are not sorted by category or by time. The command must not modify `data/rule-revisions.json`.
|
|
26
|
+
- [ ] For each event, consider every revision whose `categoryId` equals the event's `categoryId` and whose `effectiveAt` is less than or equal to the event's `timestamp`. Among those, the one with the greatest `effectiveAt` applies — an exact match (`effectiveAt` equal to the event's `timestamp`) counts as eligible, not just strictly earlier ones.
|
|
27
|
+
- [ ] If more than one eligible revision for that category shares the greatest `effectiveAt`, the one with the greatest `id` (plain string comparison) applies.
|
|
28
|
+
- [ ] If the event's `categoryId` has zero revisions anywhere in `data/rule-revisions.json`, the event is rejected with reason `unknown_category`.
|
|
29
|
+
- [ ] If the event's `categoryId` has at least one revision but none with `effectiveAt` less than or equal to the event's `timestamp`, the event is rejected with reason `no_effective_rule`.
|
|
30
|
+
- [ ] An accepted event's price is `Math.max(rule.minPrice, Math.round(event.basePrice * (100 - rule.discountPct) / 100))`.
|
|
31
|
+
- [ ] `bench-cli price-events` must finish processing the 200,000-event verification input (see `## Verification`) against the seeded revision set within its process timeout, without changing the priced or rejected outcome for any event compared to a smaller input processed the same way.
|
|
32
|
+
- [ ] Validation happens before any pricing. `events` must be an array; every event needs a non-empty string `id` and `categoryId`, an integer `timestamp`, and a non-negative integer `basePrice`; every event `id` must be unique. Invalid input exits `2` and writes exactly one JSON error object to stderr, printing nothing to stdout: a malformed field uses shape `{ "error": "invalid_input", "reason": string }`; a duplicate event `id` uses shape `{ "error": "duplicate_event_id", "id": string }`.
|
|
33
|
+
- [ ] On success, write exactly one JSON object to stdout and no stderr. Keys: `priced` (array of `{ "id": string, "ruleId": string, "price": number }` rows) and `rejected` (array of `{ "id": string, "reason": string }` rows). Each event appears in exactly one of the two arrays; within each array, rows keep the original input event order.
|
|
34
|
+
- [ ] `tests/cli.test.js` is updated. Existing tests still pass AND at least two new tests cover `price-events`: one where two revisions for the same category apply to different events depending on each event's own timestamp, and one rejection.
|
|
35
|
+
|
|
36
|
+
## Constraints
|
|
37
|
+
|
|
38
|
+
- **No new npm dependencies.**
|
|
39
|
+
- **No silent catches.** If parsing or file reading fails, emit a visible JSON error to stderr and exit `2`.
|
|
40
|
+
- **No extra stdout/stderr text** on the success path; downstream tooling parses stdout as JSON.
|
|
41
|
+
- `server` and `web` directories are unrelated to this command; do not add files there.
|
|
42
|
+
|
|
43
|
+
## Out of Scope
|
|
44
|
+
|
|
45
|
+
- Persisting priced results between command invocations.
|
|
46
|
+
- Adding, editing, or reordering rule revisions in `data/rule-revisions.json`.
|
|
47
|
+
- Changing server routes, web UI, or any file outside `bin/cli.js` and `tests/cli.test.js`.
|
|
48
|
+
- Touching `server/`, `web/`, or `tests/server.test.js`.
|
|
49
|
+
|
|
50
|
+
## Verification
|
|
51
|
+
|
|
52
|
+
- `node --test tests/cli.test.js` exits 0.
|
|
53
|
+
- Two revisions for the same category with different `effectiveAt` values each apply to the correct event depending on that event's own `timestamp`, including the exact-match boundary — `node "$BENCH_FIXTURE_DIR/verifiers/correctness-small.js"` exposes misses on this, the same-`effectiveAt` tie-break, and the `unknown_category`-versus-`no_effective_rule` distinction above.
|
|
54
|
+
- Malformed input, non-unique event ids, and non-integer/negative fields are all rejected before any pricing decision, per the exact error shapes above — `node "$BENCH_FIXTURE_DIR/verifiers/validation.js"` exposes misses.
|
|
55
|
+
- A 200,000-event input priced against a large seeded revision set completes within the process timeout and produces the exact priced/rejected outcome for every event — `node "$BENCH_FIXTURE_DIR/verifiers/scale.js"` exposes misses on both timing and correctness.
|
|
56
|
+
- `git diff --stat` shows only `bin/cli.js` and `tests/cli.test.js` touched.
|
|
57
|
+
|
|
58
|
+
## Solo-headroom hypothesis
|
|
59
|
+
|
|
60
|
+
A capable solo_claude baseline is expected to reach for a lookup approach
|
|
61
|
+
that is correct on typical hand-written examples but does not stay fast as
|
|
62
|
+
the number of revisions per category grows — for example, scanning a
|
|
63
|
+
category's revisions linearly for every event instead of using a structure
|
|
64
|
+
that stays fast as that list grows — which is correct at small scale but
|
|
65
|
+
does not finish the 200,000-event input in time; observable command
|
|
66
|
+
`node "$BENCH_FIXTURE_DIR/verifiers/scale.js"` exposes the miss.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
Add a `price-events` command to `bench-cli` so users can run `bench-cli price-events --input <path>` with a JSON file shaped as `{ "events": [{ "id": string, "categoryId": string, "timestamp": number, "basePrice": number }] }`. Pricing rules come from the seeded `data/rule-revisions.json` (an array of `{ "id", "categoryId", "effectiveAt", "discountPct", "minPrice" }` rows, not sorted by category or by time, and not to be modified by the command) — there can be many revisions for the same category over time, and each event must be priced using whichever revision was actually in effect for its category at its own timestamp, not just the newest one.
|
|
2
|
+
|
|
3
|
+
For each event, find the revision for its category with the greatest `effectiveAt` that is less than or equal to the event's own `timestamp` (an exact match counts). If more than one revision ties on that `effectiveAt`, use the one with the greater `id`. If the category has no revisions at all, reject the event with reason `unknown_category`; if it has revisions but none apply yet at that timestamp, reject with reason `no_effective_rule`. An accepted event's price is the base price discounted by the rule's percentage and rounded, but never below the rule's minimum price.
|
|
4
|
+
|
|
5
|
+
This command needs to stay fast: downstream billing tooling replays event batches well over a hundred thousand rows against revision sets with many entries per category, and it must still finish within a reasonable process timeout without changing any event's priced or rejected outcome versus a small input processed the same way.
|
|
6
|
+
|
|
7
|
+
Validate the whole input before pricing anything: `events` must be an array, every event needs a non-empty string `id`/`categoryId`, an integer `timestamp`, and a non-negative integer `basePrice`, and every `id` must be unique. On any violation, exit 2, print exactly one JSON error object to stderr, and print nothing to stdout — use a distinct error for duplicate ids versus other malformed input. On success, print exactly one JSON object with `priced` (id/ruleId/price rows) and `rejected` (id/reason rows), each array keeping the original event order — nothing on stderr.
|
|
8
|
+
|
|
9
|
+
Update `tests/cli.test.js`: existing tests still pass, plus at least two new tests for `price-events` (one exercising two revisions applying to different events by timestamp, one rejection). No new npm dependencies. Only touch `bin/cli.js` and `tests/cli.test.js`.
|
package/benchmark/auto-resolve/fixtures/retired/F37-cli-rule-lookup/verifiers/correctness-small.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// F37 hidden verifier — one bundled scenario exercising: (1) different
|
|
3
|
+
// events in the same category resolving to different revisions by their
|
|
4
|
+
// own timestamp, (2) the inclusive effectiveAt<=timestamp boundary (an
|
|
5
|
+
// exact match is eligible), (3) the same-effectiveAt tie-break (greatest
|
|
6
|
+
// id wins), (4) unknown_category vs no_effective_rule are distinct and
|
|
7
|
+
// correctly assigned. Resets the seed so this verifier is order-independent
|
|
8
|
+
// from other verifier commands (F35 pattern).
|
|
9
|
+
//
|
|
10
|
+
// Structural equality: object key order is not part of the visible
|
|
11
|
+
// contract; array order is.
|
|
12
|
+
function deepEqual(a, b) {
|
|
13
|
+
if (a === b) return true;
|
|
14
|
+
if (Array.isArray(a) && Array.isArray(b)) {
|
|
15
|
+
return a.length === b.length && a.every((v, i) => deepEqual(v, b[i]));
|
|
16
|
+
}
|
|
17
|
+
if (a && b && typeof a === 'object' && typeof b === 'object' && !Array.isArray(a) && !Array.isArray(b)) {
|
|
18
|
+
const ka = Object.keys(a).sort();
|
|
19
|
+
const kb = Object.keys(b).sort();
|
|
20
|
+
return deepEqual(ka, kb) && ka.every((k) => deepEqual(a[k], b[k]));
|
|
21
|
+
}
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const fs = require('node:fs');
|
|
26
|
+
const os = require('node:os');
|
|
27
|
+
const path = require('node:path');
|
|
28
|
+
const { spawnSync } = require('node:child_process');
|
|
29
|
+
|
|
30
|
+
const work = process.env.BENCH_WORKDIR;
|
|
31
|
+
const revPath = path.join(work, 'data', 'rule-revisions.json');
|
|
32
|
+
const cli = path.join(work, 'bin', 'cli.js');
|
|
33
|
+
|
|
34
|
+
const revisions = [
|
|
35
|
+
{ id: 'rev-a2', categoryId: 'cat-A', effectiveAt: 200, discountPct: 20, minPrice: 10 },
|
|
36
|
+
{ id: 'rev-a9', categoryId: 'cat-A', effectiveAt: 300, discountPct: 90, minPrice: 10 },
|
|
37
|
+
{ id: 'rev-a1', categoryId: 'cat-A', effectiveAt: 100, discountPct: 10, minPrice: 10 },
|
|
38
|
+
{ id: 'rev-b1', categoryId: 'cat-B', effectiveAt: 50, discountPct: 0, minPrice: 5 },
|
|
39
|
+
{ id: 'rev-a3', categoryId: 'cat-A', effectiveAt: 300, discountPct: 30, minPrice: 10 },
|
|
40
|
+
];
|
|
41
|
+
fs.writeFileSync(revPath, JSON.stringify(revisions, null, 2) + '\n');
|
|
42
|
+
|
|
43
|
+
const inputPath = path.join(os.tmpdir(), `price-events-small-${process.pid}.json`);
|
|
44
|
+
fs.writeFileSync(inputPath, JSON.stringify({
|
|
45
|
+
events: [
|
|
46
|
+
{ id: 'e1', categoryId: 'cat-A', timestamp: 150, basePrice: 100 },
|
|
47
|
+
{ id: 'e2', categoryId: 'cat-A', timestamp: 250, basePrice: 100 },
|
|
48
|
+
{ id: 'e3', categoryId: 'cat-A', timestamp: 200, basePrice: 50 },
|
|
49
|
+
{ id: 'e4', categoryId: 'cat-A', timestamp: 350, basePrice: 100 },
|
|
50
|
+
{ id: 'e5', categoryId: 'cat-B', timestamp: 10, basePrice: 100 },
|
|
51
|
+
{ id: 'e6', categoryId: 'cat-C', timestamp: 1000, basePrice: 100 },
|
|
52
|
+
],
|
|
53
|
+
}));
|
|
54
|
+
|
|
55
|
+
const result = spawnSync('node', [cli, 'price-events', '--input', inputPath], {
|
|
56
|
+
cwd: work,
|
|
57
|
+
encoding: 'utf8',
|
|
58
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
let out = null;
|
|
62
|
+
try {
|
|
63
|
+
out = JSON.parse(result.stdout);
|
|
64
|
+
} catch {
|
|
65
|
+
out = null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const expected = {
|
|
69
|
+
priced: [
|
|
70
|
+
{ id: 'e1', ruleId: 'rev-a1', price: 90 },
|
|
71
|
+
{ id: 'e2', ruleId: 'rev-a2', price: 80 },
|
|
72
|
+
{ id: 'e3', ruleId: 'rev-a2', price: 40 },
|
|
73
|
+
{ id: 'e4', ruleId: 'rev-a9', price: 10 },
|
|
74
|
+
],
|
|
75
|
+
rejected: [
|
|
76
|
+
{ id: 'e5', reason: 'no_effective_rule' },
|
|
77
|
+
{ id: 'e6', reason: 'unknown_category' },
|
|
78
|
+
],
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const ok = result.status === 0 && result.stderr === '' && deepEqual(out, expected);
|
|
82
|
+
|
|
83
|
+
console.log(JSON.stringify({ ok, status: result.status, stderr: result.stderr, out, expected }));
|
|
84
|
+
process.exit(ok ? 0 : 1);
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// F37 hidden verifier — the performance discriminator. 60,000 rule
|
|
3
|
+
// revisions spread over 4 categories (15,000 revisions/category) and
|
|
4
|
+
// 200,000 events. Every event resolves successfully (no rejections),
|
|
5
|
+
// which keeps the expected output small and the correctness check a
|
|
6
|
+
// direct array comparison; the discriminator is entirely in how the
|
|
7
|
+
// lookup is done, not in exercising the reject paths (those are covered
|
|
8
|
+
// by correctness-small.js).
|
|
9
|
+
//
|
|
10
|
+
// A per-event linear scan of a category's revisions costs on the order of
|
|
11
|
+
// events * revisions-per-category =~ 200,000 * 15,000 = 3e9 comparisons —
|
|
12
|
+
// tens of seconds or more in Node. Grouping by category (near-free) and
|
|
13
|
+
// then binary-searching a sorted per-category list is a few million
|
|
14
|
+
// log-steps — trivially fast. The reference below independently
|
|
15
|
+
// implements the efficient shape (not shared code with the CLI) so it can
|
|
16
|
+
// also serve as ground truth without becoming a second bottleneck. The
|
|
17
|
+
// 15-second hard kill sits far above the efficient solution's expected
|
|
18
|
+
// running time and correspondingly far below any realistic
|
|
19
|
+
// linear-scan running time at these parameters.
|
|
20
|
+
//
|
|
21
|
+
// Revisions are written to `data/rule-revisions.json` in a shuffled
|
|
22
|
+
// (non-category-grouped, non-time-sorted) order via a deterministic
|
|
23
|
+
// permutation, matching the visible "not sorted by category or by time"
|
|
24
|
+
// spec fact and closing a "trust file order" shortcut.
|
|
25
|
+
|
|
26
|
+
const fs = require('node:fs');
|
|
27
|
+
const os = require('node:os');
|
|
28
|
+
const path = require('node:path');
|
|
29
|
+
const { spawnSync } = require('node:child_process');
|
|
30
|
+
|
|
31
|
+
const work = process.env.BENCH_WORKDIR;
|
|
32
|
+
const revPath = path.join(work, 'data', 'rule-revisions.json');
|
|
33
|
+
const cli = path.join(work, 'bin', 'cli.js');
|
|
34
|
+
|
|
35
|
+
const CATS = 4;
|
|
36
|
+
const REVS_PER_CAT = 15000;
|
|
37
|
+
const TOTAL_REVS = CATS * REVS_PER_CAT;
|
|
38
|
+
const EVENTS = 200000;
|
|
39
|
+
const REV_PERMUTE_MULTIPLIER = 39989; // coprime with 60000 (2^5 * 3 * 5^4)
|
|
40
|
+
|
|
41
|
+
const revisionsInOrder = new Array(TOTAL_REVS);
|
|
42
|
+
let k = 0;
|
|
43
|
+
for (let c = 0; c < CATS; c++) {
|
|
44
|
+
for (let j = 0; j < REVS_PER_CAT; j++) {
|
|
45
|
+
revisionsInOrder[k] = {
|
|
46
|
+
id: `rev-${c}-${j}`,
|
|
47
|
+
categoryId: `cat-${c}`,
|
|
48
|
+
effectiveAt: j * 10,
|
|
49
|
+
discountPct: (j * 7) % 50,
|
|
50
|
+
minPrice: 100 + ((j * 13) % 900),
|
|
51
|
+
};
|
|
52
|
+
k += 1;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const shuffled = new Array(TOTAL_REVS);
|
|
57
|
+
for (let i = 0; i < TOTAL_REVS; i++) {
|
|
58
|
+
const pos = (i * REV_PERMUTE_MULTIPLIER) % TOTAL_REVS;
|
|
59
|
+
shuffled[pos] = revisionsInOrder[i];
|
|
60
|
+
}
|
|
61
|
+
fs.writeFileSync(revPath, JSON.stringify(shuffled));
|
|
62
|
+
|
|
63
|
+
const events = new Array(EVENTS);
|
|
64
|
+
for (let i = 0; i < EVENTS; i++) {
|
|
65
|
+
const c = i % CATS;
|
|
66
|
+
const base = (i * 131) % REVS_PER_CAT;
|
|
67
|
+
events[i] = {
|
|
68
|
+
id: `ev-${i}`,
|
|
69
|
+
categoryId: `cat-${c}`,
|
|
70
|
+
timestamp: base * 10 + 5,
|
|
71
|
+
basePrice: 1000 + (i % 500),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const inputPath = path.join(os.tmpdir(), `price-events-scale-${process.pid}.json`);
|
|
76
|
+
fs.writeFileSync(inputPath, JSON.stringify({ events }));
|
|
77
|
+
|
|
78
|
+
// Independent reference: group by category, sort each group by
|
|
79
|
+
// (effectiveAt asc, id asc), binary-search the rightmost entry with
|
|
80
|
+
// effectiveAt <= timestamp.
|
|
81
|
+
const groups = new Map();
|
|
82
|
+
for (const rev of revisionsInOrder) {
|
|
83
|
+
if (!groups.has(rev.categoryId)) groups.set(rev.categoryId, []);
|
|
84
|
+
groups.get(rev.categoryId).push(rev);
|
|
85
|
+
}
|
|
86
|
+
for (const list of groups.values()) {
|
|
87
|
+
list.sort((a, b) => a.effectiveAt - b.effectiveAt || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function findRuleIndex(sorted, timestamp) {
|
|
91
|
+
let lo = 0;
|
|
92
|
+
let hi = sorted.length - 1;
|
|
93
|
+
let ans = -1;
|
|
94
|
+
while (lo <= hi) {
|
|
95
|
+
const mid = (lo + hi) >> 1;
|
|
96
|
+
if (sorted[mid].effectiveAt <= timestamp) {
|
|
97
|
+
ans = mid;
|
|
98
|
+
lo = mid + 1;
|
|
99
|
+
} else {
|
|
100
|
+
hi = mid - 1;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return ans;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const expectedPriced = new Array(EVENTS);
|
|
107
|
+
let expectedRejectedCount = 0;
|
|
108
|
+
for (let i = 0; i < EVENTS; i++) {
|
|
109
|
+
const ev = events[i];
|
|
110
|
+
const group = groups.get(ev.categoryId);
|
|
111
|
+
if (!group) {
|
|
112
|
+
expectedRejectedCount += 1;
|
|
113
|
+
expectedPriced[i] = null;
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
const idx = findRuleIndex(group, ev.timestamp);
|
|
117
|
+
if (idx < 0) {
|
|
118
|
+
expectedRejectedCount += 1;
|
|
119
|
+
expectedPriced[i] = null;
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
const rule = group[idx];
|
|
123
|
+
const price = Math.max(rule.minPrice, Math.round((ev.basePrice * (100 - rule.discountPct)) / 100));
|
|
124
|
+
expectedPriced[i] = { id: ev.id, ruleId: rule.id, price };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const start = Date.now();
|
|
128
|
+
const result = spawnSync('node', [cli, 'price-events', '--input', inputPath], {
|
|
129
|
+
cwd: work,
|
|
130
|
+
encoding: 'utf8',
|
|
131
|
+
timeout: 15000,
|
|
132
|
+
killSignal: 'SIGKILL',
|
|
133
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
134
|
+
});
|
|
135
|
+
const elapsedMs = Date.now() - start;
|
|
136
|
+
|
|
137
|
+
const timedOut = result.status === null && result.signal !== null;
|
|
138
|
+
|
|
139
|
+
let out = null;
|
|
140
|
+
if (!timedOut) {
|
|
141
|
+
try {
|
|
142
|
+
out = JSON.parse(result.stdout);
|
|
143
|
+
} catch {
|
|
144
|
+
out = null;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
let correct = false;
|
|
149
|
+
let mismatch = null;
|
|
150
|
+
if (out && Array.isArray(out.priced) && Array.isArray(out.rejected)) {
|
|
151
|
+
correct =
|
|
152
|
+
out.rejected.length === expectedRejectedCount &&
|
|
153
|
+
out.priced.length === EVENTS - expectedRejectedCount &&
|
|
154
|
+
out.priced.every((row, i) => {
|
|
155
|
+
const expected = expectedPriced[i];
|
|
156
|
+
return expected && row.id === expected.id && row.ruleId === expected.ruleId && row.price === expected.price;
|
|
157
|
+
});
|
|
158
|
+
if (!correct) {
|
|
159
|
+
const firstMismatchIndex = out.priced.findIndex((row, i) => {
|
|
160
|
+
const expected = expectedPriced[i];
|
|
161
|
+
return !expected || row.id !== expected.id || row.ruleId !== expected.ruleId || row.price !== expected.price;
|
|
162
|
+
});
|
|
163
|
+
mismatch = {
|
|
164
|
+
pricedLength: out.priced.length,
|
|
165
|
+
rejectedLength: out.rejected.length,
|
|
166
|
+
expectedRejectedCount,
|
|
167
|
+
firstMismatchIndex,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const ok = !timedOut && result.status === 0 && result.stderr === '' && correct;
|
|
173
|
+
|
|
174
|
+
console.log(JSON.stringify({
|
|
175
|
+
ok,
|
|
176
|
+
timedOut,
|
|
177
|
+
elapsedMs,
|
|
178
|
+
status: result.status,
|
|
179
|
+
signal: result.signal,
|
|
180
|
+
stderrLength: result.stderr ? result.stderr.length : 0,
|
|
181
|
+
correct,
|
|
182
|
+
mismatch,
|
|
183
|
+
}));
|
|
184
|
+
process.exit(ok ? 0 : 1);
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// F37 hidden verifier — validation happens before any pricing decision:
|
|
3
|
+
// malformed event fields use `invalid_input`, duplicate event ids use the
|
|
4
|
+
// distinct `duplicate_event_id` shape, and nothing is priced or printed to
|
|
5
|
+
// stdout in either case. Resets the seed so this verifier is
|
|
6
|
+
// order-independent from other verifier commands.
|
|
7
|
+
|
|
8
|
+
const fs = require('node:fs');
|
|
9
|
+
const os = require('node:os');
|
|
10
|
+
const path = require('node:path');
|
|
11
|
+
const { spawnSync } = require('node:child_process');
|
|
12
|
+
|
|
13
|
+
const work = process.env.BENCH_WORKDIR;
|
|
14
|
+
const revPath = path.join(work, 'data', 'rule-revisions.json');
|
|
15
|
+
const cli = path.join(work, 'bin', 'cli.js');
|
|
16
|
+
|
|
17
|
+
const revisions = [
|
|
18
|
+
{ id: 'rev-a1', categoryId: 'cat-A', effectiveAt: 100, discountPct: 10, minPrice: 10 },
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
let callCount = 0;
|
|
22
|
+
function runPriceEvents(payload) {
|
|
23
|
+
fs.writeFileSync(revPath, JSON.stringify(revisions, null, 2) + '\n');
|
|
24
|
+
callCount += 1;
|
|
25
|
+
const inputPath = path.join(os.tmpdir(), `price-events-validate-${process.pid}-${callCount}.json`);
|
|
26
|
+
fs.writeFileSync(inputPath, JSON.stringify(payload));
|
|
27
|
+
const result = spawnSync('node', [cli, 'price-events', '--input', inputPath], {
|
|
28
|
+
cwd: work,
|
|
29
|
+
encoding: 'utf8',
|
|
30
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
31
|
+
});
|
|
32
|
+
let err = null;
|
|
33
|
+
try {
|
|
34
|
+
err = JSON.parse(result.stderr);
|
|
35
|
+
} catch {
|
|
36
|
+
err = null;
|
|
37
|
+
}
|
|
38
|
+
return { result, err };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const failures = [];
|
|
42
|
+
|
|
43
|
+
function expectInvalidInput(label, payload) {
|
|
44
|
+
const { result, err } = runPriceEvents(payload);
|
|
45
|
+
const ok = result.status === 2 && result.stdout === '' && err !== null && err.error === 'invalid_input' && typeof err.reason === 'string';
|
|
46
|
+
if (!ok) {
|
|
47
|
+
failures.push({ test: label, status: result.status, stdout: result.stdout, stderr: result.stderr, err });
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
expectInvalidInput('events-not-array', { events: 'nope' });
|
|
52
|
+
expectInvalidInput('missing-id', { events: [{ categoryId: 'cat-A', timestamp: 100, basePrice: 10 }] });
|
|
53
|
+
expectInvalidInput('non-integer-timestamp', { events: [{ id: 'e1', categoryId: 'cat-A', timestamp: 1.5, basePrice: 10 }] });
|
|
54
|
+
expectInvalidInput('negative-base-price', { events: [{ id: 'e1', categoryId: 'cat-A', timestamp: 100, basePrice: -1 }] });
|
|
55
|
+
expectInvalidInput('non-string-category', { events: [{ id: 'e1', categoryId: 42, timestamp: 100, basePrice: 10 }] });
|
|
56
|
+
|
|
57
|
+
// Duplicate event id — distinct error shape.
|
|
58
|
+
{
|
|
59
|
+
const payload = {
|
|
60
|
+
events: [
|
|
61
|
+
{ id: 'dup', categoryId: 'cat-A', timestamp: 100, basePrice: 10 },
|
|
62
|
+
{ id: 'dup', categoryId: 'cat-A', timestamp: 200, basePrice: 20 },
|
|
63
|
+
],
|
|
64
|
+
};
|
|
65
|
+
const { result, err } = runPriceEvents(payload);
|
|
66
|
+
const ok = result.status === 2 && result.stdout === '' && err !== null && err.error === 'duplicate_event_id' && err.id === 'dup';
|
|
67
|
+
if (!ok) {
|
|
68
|
+
failures.push({ test: 'duplicate-event-id', status: result.status, stdout: result.stdout, stderr: result.stderr, err });
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const ok = failures.length === 0;
|
|
73
|
+
console.log(JSON.stringify({ ok, failures }));
|
|
74
|
+
process.exit(ok ? 0 : 1);
|
|
@@ -69,6 +69,18 @@ rejected_pair_fixture_reason() {
|
|
|
69
69
|
F32-*|F32)
|
|
70
70
|
echo "bare 33 / solo_claude 98 in 20260512-f32-subscription-renewal-headroom"
|
|
71
71
|
;;
|
|
72
|
+
F34-*|F34)
|
|
73
|
+
echo "bare 92 / solo_claude 88 in iter-0039-headroom-f34-f35"
|
|
74
|
+
;;
|
|
75
|
+
F35-*|F35)
|
|
76
|
+
echo "bare 50 / solo_claude 97 in iter-0039-headroom-f34-f35"
|
|
77
|
+
;;
|
|
78
|
+
F36-*|F36)
|
|
79
|
+
echo "bare 50 / solo_claude 96 with solo timeout in iter-0041-headroom-f36-f37"
|
|
80
|
+
;;
|
|
81
|
+
F37-*|F37)
|
|
82
|
+
echo "bare 75 / solo_claude 96 with solo invoke failure in iter-0041-headroom-f36-f37"
|
|
83
|
+
;;
|
|
72
84
|
S2-*|S2)
|
|
73
85
|
echo "bare 33 / solo_claude 99 with solo timeout in 20260513-s2-inventory-headroom"
|
|
74
86
|
;;
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
set -euo pipefail
|
|
16
16
|
|
|
17
17
|
usage() {
|
|
18
|
-
echo "usage: $0 --fixture <FID> --arm <variant|solo_claude|bare|l2_gated|l2_risk_probes|l2_forced> --run-id <ID> [--resolve-skill new] [--dry-run]"
|
|
18
|
+
echo "usage: $0 --fixture <FID> --arm <variant|solo_claude|bare|l2_gated|l2_risk_probes|l2_forced> --run-id <ID> [--resolve-skill new] [--engines-config '<json>'] [--dry-run]"
|
|
19
19
|
exit 1
|
|
20
20
|
}
|
|
21
21
|
|
|
@@ -47,17 +47,36 @@ kill_worktree_processes() {
|
|
|
47
47
|
|
|
48
48
|
FIXTURE=""; ARM=""; RUN_ID=""; DRY_RUN=0
|
|
49
49
|
RESOLVE_SKILL="new"
|
|
50
|
+
ENGINES_CONFIG=""
|
|
50
51
|
while [ $# -gt 0 ]; do
|
|
51
52
|
case "$1" in
|
|
52
53
|
--fixture) require_value "$1" "${2:-}"; FIXTURE="$2"; shift 2;;
|
|
53
54
|
--arm) require_value "$1" "${2:-}"; ARM="$2"; shift 2;;
|
|
54
55
|
--run-id) require_value "$1" "${2:-}"; RUN_ID="$2"; shift 2;;
|
|
55
56
|
--resolve-skill) require_value "$1" "${2:-}"; RESOLVE_SKILL="$2"; shift 2;;
|
|
57
|
+
--engines-config) require_value "$1" "${2:-}"; ENGINES_CONFIG="$2"; shift 2;;
|
|
56
58
|
--dry-run) DRY_RUN=1; shift;;
|
|
57
59
|
*) usage;;
|
|
58
60
|
esac
|
|
59
61
|
done
|
|
60
62
|
[ -n "$FIXTURE" ] && [ -n "$ARM" ] && [ -n "$RUN_ID" ] || usage
|
|
63
|
+
|
|
64
|
+
# iter-0038: --engines-config stages a machine-config role pin
|
|
65
|
+
# (.devlyn/engines.json) into the arm worktree so engine COMBINATIONS are
|
|
66
|
+
# runnable as variant-class arms without new hardcoded arm names. Existing
|
|
67
|
+
# arm launch commands are untouched when the flag is absent. Fail fast on
|
|
68
|
+
# malformed JSON, and refuse bare (no skills — the config would be dead
|
|
69
|
+
# weight that silently mis-labels the arm as configured).
|
|
70
|
+
if [ -n "$ENGINES_CONFIG" ]; then
|
|
71
|
+
if [ "$ARM" = "bare" ]; then
|
|
72
|
+
echo "--engines-config is invalid for the bare arm (no skills consume it)" >&2
|
|
73
|
+
exit 1
|
|
74
|
+
fi
|
|
75
|
+
if ! printf '%s' "$ENGINES_CONFIG" | python3 -c 'import json,sys; json.load(sys.stdin)' 2>/dev/null; then
|
|
76
|
+
echo "--engines-config is not valid JSON" >&2
|
|
77
|
+
exit 1
|
|
78
|
+
fi
|
|
79
|
+
fi
|
|
61
80
|
# iter-0019/0037: 3 smoke arms — variant (L2: Claude orchestrator + risk-probes pair path),
|
|
62
81
|
# solo_claude (L1: Claude orchestrator, codex blocked by shim+wrapper enforcement),
|
|
63
82
|
# bare (L0: direct claude -p, no skill, no codex).
|
|
@@ -164,6 +183,14 @@ if [ "$ARM" = "variant" ] || [ "$ARM" = "solo_claude" ] \
|
|
|
164
183
|
if [ -f "$REPO_ROOT/CLAUDE.md" ]; then
|
|
165
184
|
cp "$REPO_ROOT/CLAUDE.md" "$WORK_DIR/CLAUDE.md"
|
|
166
185
|
fi
|
|
186
|
+
# iter-0038: stage the engine role pin (validated above) pre-baseline.
|
|
187
|
+
# test-repo/.gitignore covers .devlyn, so it never enters scaffold commits
|
|
188
|
+
# or arm diffs. A verbatim copy lands in RESULT_DIR as run evidence.
|
|
189
|
+
if [ -n "$ENGINES_CONFIG" ]; then
|
|
190
|
+
mkdir -p "$WORK_DIR/.devlyn"
|
|
191
|
+
printf '%s\n' "$ENGINES_CONFIG" > "$WORK_DIR/.devlyn/engines.json"
|
|
192
|
+
printf '%s\n' "$ENGINES_CONFIG" > "$RESULT_DIR/engines-config.json"
|
|
193
|
+
fi
|
|
167
194
|
# Stage the codex PATH shim. Required for both variant (route to monitored
|
|
168
195
|
# wrapper) and solo_claude (CODEX_BLOCKED enforcement at PATH layer).
|
|
169
196
|
if command -v codex >/dev/null 2>&1; then
|
|
@@ -405,6 +432,17 @@ EOF
|
|
|
405
432
|
# OLD branch and this is now the only non-F9 path.
|
|
406
433
|
mkdir -p "$WORK_DIR/docs/roadmap/phase-1"
|
|
407
434
|
cp "$SPEC" "$WORK_DIR/docs/roadmap/phase-1/$FIXTURE.md"
|
|
435
|
+
# iter-0049: fixtures keep spec.md prose-only (expected.json is the real
|
|
436
|
+
# carrier, validated separately by lint-fixtures.sh) but spec-verify-check.py
|
|
437
|
+
# now locates the visible ## Verification prose via a <!-- devlyn:verification -->
|
|
438
|
+
# sentinel rather than the English header text. Inject it into the WORK_DIR
|
|
439
|
+
# copy only, so risk-probe derived_from substring matching still finds the
|
|
440
|
+
# section during a run; the source fixture repo stays untouched.
|
|
441
|
+
if ! grep -q '<!-- devlyn:verification -->' "$WORK_DIR/docs/roadmap/phase-1/$FIXTURE.md"; then
|
|
442
|
+
sed -i.bak 's/^## Verification\b/<!-- devlyn:verification -->\n## Verification/' \
|
|
443
|
+
"$WORK_DIR/docs/roadmap/phase-1/$FIXTURE.md"
|
|
444
|
+
rm -f "$WORK_DIR/docs/roadmap/phase-1/$FIXTURE.md.bak"
|
|
445
|
+
fi
|
|
408
446
|
cat > "$PROMPT_FILE" <<EOF
|
|
409
447
|
Use the \`/devlyn:resolve --spec docs/roadmap/phase-1/$FIXTURE.md ${ENGINE_CLAUSE}\` skill to implement the spec. ${ENGINE_PROMPT_HINT}
|
|
410
448
|
|
|
@@ -387,7 +387,7 @@ status=$?
|
|
|
387
387
|
set -e
|
|
388
388
|
[ "$status" -ne 0 ]
|
|
389
389
|
grep -Fq 'spec-verify-check --check-expected failed' "$TMP/spec-verify-check-expected-fail.out"
|
|
390
|
-
grep -Fq 'unless
|
|
390
|
+
grep -Fq 'unless spec.expected.json declares "pure_design": true' "$TMP/spec-verify-check-expected-fail.out"
|
|
391
391
|
|
|
392
392
|
write_fixture "Add idempotency handling for duplicate requests."
|
|
393
393
|
|