@pmelab/gtd 58.2.0 → 59.0.1
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/.claude-plugin/plugin.json +1 -1
- package/README.md +120 -395
- package/dist/chunks/{Schema-B3o6eRNU.mjs → testkit-07Z_ENch.mjs} +13503 -3460
- package/dist/chunks/{wire-C12dahrH.mjs → wire-BTUhRGXj.mjs} +1 -1
- package/dist/flows.mjs +1 -1
- package/dist/gtd.bundle.mjs +484 -497
- package/dist/testkit/gherkin.mjs +145 -65
- package/dist/testkit.mjs +1 -1
- package/dist/wire.mjs +1 -1
- package/package.json +3 -42
- package/skills/authoring/SKILL.md +34 -27
- package/src/cli/Cli.test.ts +148 -71
- package/src/cli/Cli.ts +384 -463
- package/src/cli/Help.test.ts +47 -0
- package/src/cli/Help.ts +70 -0
- package/src/cli/Program.test.ts +127 -33
- package/src/cli/Program.ts +70 -25
- package/src/edge/Edge.ts +4 -4
- package/src/emit/Emit.test.ts +1 -1
- package/src/emit/Emit.ts +1 -1
- package/src/flags/index.test.ts +0 -13
- package/src/flags/index.ts +1 -3
- package/src/flows/factory.ts +3 -1
- package/src/gherkin/index.ts +135 -71
- package/src/gherkin/messages.test.ts +90 -0
- package/src/gherkin/messages.ts +84 -0
- package/src/install/Install.test.ts +14 -11
- package/src/install/Install.ts +38 -15
- package/src/install/index.ts +1 -2
- package/src/replay/Replay.test.ts +1 -1
- package/src/replay/index.ts +1 -1
- package/src/step/planStart.ts +6 -1
- package/src/testkit/TestRepo.test.ts +47 -2
- package/src/testkit/TestRepo.ts +51 -6
- package/src/twins/EmittedScriptRecognizer.ts +5 -8
- package/src/twins/FlowScripts.ts +1 -1
- package/src/wire/Land.ts +1 -1
- package/src/workflow/index.ts +1 -0
- package/src/workflow/load.test.ts +138 -113
- package/src/workflow/load.ts +7 -7
- package/src/workflow/skills.test.ts +5 -5
- package/src/workflow/skills.ts +1 -1
- package/src/workflow/vars.test.ts +30 -3
- package/src/workflow/vars.ts +34 -2
- package/src/workflow-kit/index.ts +1 -1
- package/dist/chunks/reserved-lMYCaapY.mjs +0 -1990
- package/dist/chunks/review-Bfaror38.mjs +0 -427
- package/dist/chunks/tail-Bd7L940p.mjs +0 -355
- package/dist/chunks/testkit-JDtpNKyi.mjs +0 -10410
- package/dist/workflows/cleanup.mjs +0 -36
- package/dist/workflows/feature.mjs +0 -1018
- package/dist/workflows/fix.mjs +0 -41
- package/dist/workflows/review.mjs +0 -45
- package/dist/workflows/rollout.mjs +0 -48
- package/dist/workflows.mjs +0 -40651
- package/src/install/ConfigScaffold.ts +0 -22
- package/src/workflows/cleanup/cleanup-path.feature +0 -94
- package/src/workflows/cleanup/cleanup.feature +0 -315
- package/src/workflows/cleanup/cleanup.test.ts +0 -276
- package/src/workflows/cleanup/cleanup.ts +0 -52
- package/src/workflows/cleanup/index.ts +0 -6
- package/src/workflows/feature/acceptance-criteria-tier.feature +0 -25
- package/src/workflows/feature/acceptance-criteria.feature +0 -389
- package/src/workflows/feature/criteria-coverage.feature +0 -102
- package/src/workflows/feature/criteria.test.ts +0 -147
- package/src/workflows/feature/criteria.ts +0 -267
- package/src/workflows/feature/fast-path.feature +0 -93
- package/src/workflows/feature/feature.test.ts +0 -338
- package/src/workflows/feature/feature.ts +0 -156
- package/src/workflows/feature/green.ts +0 -19
- package/src/workflows/feature/index.ts +0 -18
- package/src/workflows/feature/lens-models.feature +0 -69
- package/src/workflows/feature/lens-review.feature +0 -566
- package/src/workflows/feature/naive-acceptance.feature +0 -147
- package/src/workflows/feature/naive-test.feature +0 -263
- package/src/workflows/feature/one-step-land.feature +0 -93
- package/src/workflows/feature/planning.ts +0 -41
- package/src/workflows/feature/refactor-absorbs.feature +0 -131
- package/src/workflows/feature/refactor-first.feature +0 -158
- package/src/workflows/feature/refactor-lens-models.feature +0 -79
- package/src/workflows/feature/refactor-rebuild.feature +0 -273
- package/src/workflows/feature/refactor-review.feature +0 -1528
- package/src/workflows/feature/refactor.test.ts +0 -1220
- package/src/workflows/feature/refactor.ts +0 -368
- package/src/workflows/feature/release-flag.feature +0 -167
- package/src/workflows/feature/release-flag.test.ts +0 -49
- package/src/workflows/feature/release-flag.ts +0 -46
- package/src/workflows/feature/review-gate.feature +0 -241
- package/src/workflows/feature/reviewer-walkthrough.feature +0 -142
- package/src/workflows/feature/risk-tier.feature +0 -351
- package/src/workflows/feature/seam-rejection.feature +0 -146
- package/src/workflows/feature/seam.feature +0 -297
- package/src/workflows/feature/seam.test.ts +0 -527
- package/src/workflows/feature/seam.ts +0 -426
- package/src/workflows/feature/standards.feature +0 -354
- package/src/workflows/feature/steps.test.ts +0 -65
- package/src/workflows/feature/steps.ts +0 -45
- package/src/workflows/feature/tier-pin.feature +0 -66
- package/src/workflows/feature/tier.test.ts +0 -166
- package/src/workflows/feature/tier.ts +0 -56
- package/src/workflows/fix/fix-lens-origin.feature +0 -114
- package/src/workflows/fix/fix-quality.feature +0 -212
- package/src/workflows/fix/fix.feature +0 -79
- package/src/workflows/fix/fix.test.ts +0 -74
- package/src/workflows/fix/fix.ts +0 -52
- package/src/workflows/fix/index.ts +0 -6
- package/src/workflows/index.ts +0 -47
- package/src/workflows/review/index.ts +0 -6
- package/src/workflows/review/review-door.feature +0 -199
- package/src/workflows/review/review.test.ts +0 -27
- package/src/workflows/review/review.ts +0 -60
- package/src/workflows/rollout/index.ts +0 -6
- package/src/workflows/rollout/rollout.feature +0 -510
- package/src/workflows/rollout/rollout.test.ts +0 -263
- package/src/workflows/rollout/rollout.ts +0 -82
- package/src/workflows/shared/__fixtures__/feature-collecting-prompt.txt +0 -98
- package/src/workflows/shared/access.test.ts +0 -62
- package/src/workflows/shared/access.ts +0 -24
- package/src/workflows/shared/config.fixture.ts +0 -12
- package/src/workflows/shared/fences.ts +0 -24
- package/src/workflows/shared/health.test.ts +0 -148
- package/src/workflows/shared/health.ts +0 -191
- package/src/workflows/shared/learn.test.ts +0 -140
- package/src/workflows/shared/learn.ts +0 -201
- package/src/workflows/shared/lenses.test.ts +0 -39
- package/src/workflows/shared/lenses.ts +0 -294
- package/src/workflows/shared/options.test.ts +0 -157
- package/src/workflows/shared/options.ts +0 -92
- package/src/workflows/shared/progress.feature +0 -124
- package/src/workflows/shared/prose.ts +0 -263
- package/src/workflows/shared/recheck.feature +0 -60
- package/src/workflows/shared/registry.test.ts +0 -345
- package/src/workflows/shared/registry.ts +0 -183
- package/src/workflows/shared/reserved.ts +0 -11
- package/src/workflows/shared/review.test.ts +0 -1062
- package/src/workflows/shared/review.ts +0 -446
- package/src/workflows/shared/skills.test.ts +0 -121
- package/src/workflows/shared/skills.ts +0 -49
- package/src/workflows/shared/standards.test.ts +0 -90
- package/src/workflows/shared/standards.ts +0 -156
- package/src/workflows/shared/steps.test.ts +0 -219
- package/src/workflows/shared/steps.ts +0 -201
- package/src/workflows/shared/tail.test.ts +0 -454
- package/src/workflows/shared/tail.ts +0 -264
- package/src/workflows/shared/text.fixture.ts +0 -128
- package/src/workflows/shared/text.test.ts +0 -1430
- package/src/workflows/shared/text.ts +0 -1700
- package/src/workflows/shared/threads.test.ts +0 -129
- package/src/workflows/shared/vars.ts +0 -47
- package/src/workflows/shared/workflows.steps.ts +0 -54
|
@@ -1,148 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from "vitest"
|
|
2
|
-
import { toRequest } from "../../judges/index.js"
|
|
3
|
-
import { testRepo } from "../../testkit/index.js"
|
|
4
|
-
import { retryQuestion } from "./health.js"
|
|
5
|
-
|
|
6
|
-
describe("retryQuestion", () => {
|
|
7
|
-
it.each([true, false])("is sendable to jev (comparable=%s)", (comparable) => {
|
|
8
|
-
const { primitive, ...rest } = retryQuestion(comparable)
|
|
9
|
-
const { questions } = toRequest([{ ...rest, primitive } as never], {})
|
|
10
|
-
expect(
|
|
11
|
-
Object.keys((questions["verdict"] as { criteria: object }).criteria).length,
|
|
12
|
-
).toBeGreaterThanOrEqual(2)
|
|
13
|
-
})
|
|
14
|
-
})
|
|
15
|
-
|
|
16
|
-
type W = { write(p: string, c: string): void }
|
|
17
|
-
|
|
18
|
-
describe("gate guard", () => {
|
|
19
|
-
const config = (guard: string) =>
|
|
20
|
-
[
|
|
21
|
-
`import { gate } from "@pmelab/gtd/workflows"`,
|
|
22
|
-
`import { agent, refuse } from "@pmelab/gtd/flows"`,
|
|
23
|
-
`export const held = async () => { await agent("first", "go"); ${guard}; await gate("suite", "fix it", guard) }`,
|
|
24
|
-
`export default held`,
|
|
25
|
-
].join("\n")
|
|
26
|
-
|
|
27
|
-
const red = {
|
|
28
|
-
first: (_: unknown, r: W) => r.write("f", "1\n"),
|
|
29
|
-
"suite.check": (_: unknown, r: W) => r.write(".gtd/FEEDBACK.md", "red\n"),
|
|
30
|
-
"suite.recheck": (_: unknown, r: W) => r.write(".gtd/FEEDBACK.md", "red again\n"),
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
it("runs the guard after a blocked landing and lets its refusal stop it", async () => {
|
|
34
|
-
const repo = testRepo({
|
|
35
|
-
workflow: config('const guard = () => { refuse("guarded: nope") }'),
|
|
36
|
-
env: { GTD_TEST_COMMAND: "true" },
|
|
37
|
-
})
|
|
38
|
-
await repo.start("held")
|
|
39
|
-
await repo.drive(red)
|
|
40
|
-
await expect(repo.drive({ "suite.blocked": (_, r) => r.write("x", "1\n") })).rejects.toThrow(
|
|
41
|
-
"guarded: nope",
|
|
42
|
-
)
|
|
43
|
-
})
|
|
44
|
-
|
|
45
|
-
it("never runs the guard after the check step", async () => {
|
|
46
|
-
const repo = testRepo({
|
|
47
|
-
workflow: config('const guard = () => { refuse("guarded: nope") }'),
|
|
48
|
-
env: { GTD_TEST_COMMAND: "true" },
|
|
49
|
-
})
|
|
50
|
-
await repo.start("held")
|
|
51
|
-
const rest = await repo.drive(red)
|
|
52
|
-
expect(rest.state).toBe("suite.blocked")
|
|
53
|
-
})
|
|
54
|
-
})
|
|
55
|
-
|
|
56
|
-
describe("healthy describeGuard", () => {
|
|
57
|
-
const config = (options: string) =>
|
|
58
|
-
[
|
|
59
|
-
`import { healthy } from "@pmelab/gtd/workflows"`,
|
|
60
|
-
`import { agent, read, refuse } from "@pmelab/gtd/flows"`,
|
|
61
|
-
`const describeGuard = () => { refuse("describe-guarded: nope") }`,
|
|
62
|
-
`const guard = () => { refuse("guarded: nope") }`,
|
|
63
|
-
`export const held = async () => { await agent("first", "go"); await healthy(() => agent("fix", "go"), ${options}) }`,
|
|
64
|
-
`export default held`,
|
|
65
|
-
].join("\n")
|
|
66
|
-
|
|
67
|
-
let runs = 0
|
|
68
|
-
const handlers = (extra: Record<string, unknown>) => ({
|
|
69
|
-
"health.check": (_: unknown, r: W) => r.write(".gtd/FEEDBACK.md", `red ${runs++}\n`),
|
|
70
|
-
"health.recheck": (_: unknown, r: W) => r.write(".gtd/FEEDBACK.md", `red ${runs++}\n`),
|
|
71
|
-
"health.judge": () => '[{"id":"verdict","answer":"progress","p":0.9}]',
|
|
72
|
-
fix: (_: unknown, r: W) => r.write("f", `${runs}\n`),
|
|
73
|
-
first: (_: unknown, r: W) => r.write("start", "1\n"),
|
|
74
|
-
...extra,
|
|
75
|
-
})
|
|
76
|
-
const make = (options: string) =>
|
|
77
|
-
testRepo({ workflow: config(options), env: { GTD_TEST_COMMAND: "true" } })
|
|
78
|
-
|
|
79
|
-
it("runs after a describe landing and its refusal stops the turn", async () => {
|
|
80
|
-
runs = 0
|
|
81
|
-
const repo = make("{ describeGuard }")
|
|
82
|
-
await repo.start("held")
|
|
83
|
-
await expect(repo.drive(handlers({ "health.describe": () => undefined }))).rejects.toThrow(
|
|
84
|
-
"describe-guarded: nope",
|
|
85
|
-
)
|
|
86
|
-
})
|
|
87
|
-
|
|
88
|
-
it("never runs after the human stop rest", async () => {
|
|
89
|
-
runs = 0
|
|
90
|
-
// Refuses only once a person's edit exists, which only a post-stop run can see.
|
|
91
|
-
const repo = make(
|
|
92
|
-
'{ describeGuard: () => { if (read("human.txt") !== undefined) refuse("describe-guarded: late") } }',
|
|
93
|
-
)
|
|
94
|
-
await repo.start("held")
|
|
95
|
-
const rest = await repo.drive(handlers({ "health.describe": () => undefined }))
|
|
96
|
-
expect(rest.state).toBe("health.stop")
|
|
97
|
-
const after = await repo.drive(
|
|
98
|
-
handlers({ "health.stop": (_: unknown, r: W) => r.write("human.txt", "edit\n") }),
|
|
99
|
-
)
|
|
100
|
-
expect(after.state).not.toBe("health.stop")
|
|
101
|
-
})
|
|
102
|
-
|
|
103
|
-
it("keeps guard running after describe", async () => {
|
|
104
|
-
runs = 0
|
|
105
|
-
// Refuses only once the describe turn has written its analysis.
|
|
106
|
-
const repo = make(
|
|
107
|
-
'{ guard: () => { if (read(".gtd/ESCALATION.md") !== undefined) refuse("guarded: nope") } }',
|
|
108
|
-
)
|
|
109
|
-
await repo.start("held")
|
|
110
|
-
await expect(
|
|
111
|
-
repo.drive(
|
|
112
|
-
handlers({
|
|
113
|
-
"health.describe": (_: unknown, r: W) => r.write(".gtd/ESCALATION.md", "why\n"),
|
|
114
|
-
}),
|
|
115
|
-
),
|
|
116
|
-
).rejects.toThrow("guarded: nope")
|
|
117
|
-
})
|
|
118
|
-
})
|
|
119
|
-
|
|
120
|
-
describe("healthy at the fix cap", () => {
|
|
121
|
-
// Blank reports are not comparable; at an 8-byte budget the cut trims both.
|
|
122
|
-
it.each([
|
|
123
|
-
["could not compare", "", (n: number) => "\n".repeat(n)],
|
|
124
|
-
["saw only cut", `, judgeBudgetBytes: "8"`, (n: number) => `a long failing report ${n}\n`],
|
|
125
|
-
])("grants no extra turn on progress the judge %s", async (_, extra, report) => {
|
|
126
|
-
let runs = 0
|
|
127
|
-
const repo = testRepo({
|
|
128
|
-
workflow: [
|
|
129
|
-
`import { healthy } from "@pmelab/gtd/workflows"`,
|
|
130
|
-
`import { agent } from "@pmelab/gtd/flows"`,
|
|
131
|
-
`export const defaults = { judgeIdenticalMinP: "0.7"${extra} }`,
|
|
132
|
-
`export const held = async () => { await agent("first", "go"); await healthy(() => agent("fix", "go")) }`,
|
|
133
|
-
`export default held`,
|
|
134
|
-
].join("\n"),
|
|
135
|
-
env: { GTD_TEST_COMMAND: "true" },
|
|
136
|
-
})
|
|
137
|
-
await repo.start("held")
|
|
138
|
-
const rest = await repo.drive({
|
|
139
|
-
first: (_, r) => r.write("start", "1\n"),
|
|
140
|
-
"health.check": (_, r) => r.write(".gtd/FEEDBACK.md", report(++runs)),
|
|
141
|
-
"health.judge": () => '[{"id":"verdict","answer":"progress","p":0.9}]',
|
|
142
|
-
fix: (_, r) => r.write("f", `${runs}\n`),
|
|
143
|
-
"health.recheck": (_, r) => r.write(".gtd/FEEDBACK.md", "red again\n"),
|
|
144
|
-
})
|
|
145
|
-
expect(rest.state).toBe("health.describe")
|
|
146
|
-
expect(repo.subjects().filter((s) => s.startsWith("gtd(agent): fix")).length).toBe(3)
|
|
147
|
-
})
|
|
148
|
-
})
|
|
@@ -1,191 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
answered,
|
|
3
|
-
check,
|
|
4
|
-
env,
|
|
5
|
-
human,
|
|
6
|
-
judge,
|
|
7
|
-
mark,
|
|
8
|
-
numeric,
|
|
9
|
-
vars,
|
|
10
|
-
type CheckOptions,
|
|
11
|
-
type JudgeQuestion,
|
|
12
|
-
} from "../../flows/index.js"
|
|
13
|
-
import * as kit from "../../workflow-kit/index.js"
|
|
14
|
-
import { describeEscalation, escalate, escalationExhausted, ESCALATION, FEEDBACK } from "./steps.js"
|
|
15
|
-
import * as t from "./text.js"
|
|
16
|
-
|
|
17
|
-
/** Fix turns a run of red checks gets before it escalates. */
|
|
18
|
-
export const FIX_CAP = 3
|
|
19
|
-
|
|
20
|
-
/** Run the suite as step `name`; resolves `true` when it passed. A failure is in `.gtd/FEEDBACK.md`. */
|
|
21
|
-
export const baseline = (name: string, label = "Checking the baseline"): Promise<boolean> =>
|
|
22
|
-
check(name, env.testCommand ?? "", { report: FEEDBACK, label })
|
|
23
|
-
|
|
24
|
-
/**
|
|
25
|
-
* Re-run a red check as step `name` before a person sees it. Green records
|
|
26
|
-
* `Gtd-Mark: flaky=<red>` on the re-check's landing and resolves `true`.
|
|
27
|
-
*/
|
|
28
|
-
export const recheck = async (
|
|
29
|
-
name: string,
|
|
30
|
-
red: string,
|
|
31
|
-
options: Pick<CheckOptions, "sweepOnGreen"> = {},
|
|
32
|
-
): Promise<boolean> => {
|
|
33
|
-
const green = await check(name, env.testCommand ?? "", {
|
|
34
|
-
...options,
|
|
35
|
-
report: FEEDBACK,
|
|
36
|
-
label: "Re-running checks",
|
|
37
|
-
})
|
|
38
|
-
if (green) mark("flaky", red)
|
|
39
|
-
return green
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
export type { EscalationCount } from "../../workflow-kit/index.js"
|
|
43
|
-
|
|
44
|
-
const escalationSteps = (rules?: string): kit.EscalationSteps => ({
|
|
45
|
-
limit: 2,
|
|
46
|
-
describe: () => describeEscalation(rules),
|
|
47
|
-
stop: escalate,
|
|
48
|
-
exhausted: escalationExhausted,
|
|
49
|
-
})
|
|
50
|
-
|
|
51
|
-
/** Runs after an agent or human turn; refuses it by throwing (`refuse`). */
|
|
52
|
-
export type Guard = () => void
|
|
53
|
-
|
|
54
|
-
export const guarded =
|
|
55
|
-
<A extends unknown[]>(turn: (...a: A) => Promise<void>, guard?: Guard) =>
|
|
56
|
-
async (...a: A): Promise<void> => {
|
|
57
|
-
await turn(...a)
|
|
58
|
-
guard?.()
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
const guardedSteps = (
|
|
62
|
-
guard?: Guard,
|
|
63
|
-
describeGuard?: Guard,
|
|
64
|
-
rests = true,
|
|
65
|
-
rules?: string,
|
|
66
|
-
): kit.EscalationSteps => {
|
|
67
|
-
const steps = escalationSteps(rules)
|
|
68
|
-
return {
|
|
69
|
-
limit: steps.limit,
|
|
70
|
-
describe: guarded(guarded(steps.describe, guard), describeGuard),
|
|
71
|
-
stop: guarded(steps.stop, rests ? guard : undefined),
|
|
72
|
-
exhausted: guarded(steps.exhausted, rests ? guard : undefined),
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
/**
|
|
77
|
-
* Hand a red suite that repeated attempts could not fix to a person: an
|
|
78
|
-
* agent writes `.gtd/ESCALATION.md`, a human narrows it. From the third round
|
|
79
|
-
* on, the human is told the budget is spent instead.
|
|
80
|
-
*/
|
|
81
|
-
export const escalation = (
|
|
82
|
-
count: kit.EscalationCount,
|
|
83
|
-
guard?: Guard,
|
|
84
|
-
guardRests = true,
|
|
85
|
-
rules?: string,
|
|
86
|
-
): Promise<void> => kit.escalation(count, guardedSteps(guard, undefined, guardRests, rules))
|
|
87
|
-
|
|
88
|
-
/** "Is this red round the same failure as the last one?" — asked of two reports, or of nothing when one is empty. */
|
|
89
|
-
export const retryQuestion = (comparable: boolean): JudgeQuestion =>
|
|
90
|
-
comparable
|
|
91
|
-
? {
|
|
92
|
-
id: "verdict",
|
|
93
|
-
primitive: "choice",
|
|
94
|
-
instructions:
|
|
95
|
-
"Compare this round's failing check output (current) against the previous round's (previous), both in state. Classify the change.",
|
|
96
|
-
criteria:
|
|
97
|
-
"identical: the same failure restated, byte-for-byte or the same root cause — the trailing `<!-- gtd check <sha> -->` stamp is already stripped from both, but ignore it too if it ever reappears. new-failure: a materially different symptom than previous. progress: still red, but measurably closer to green (fewer failures, a later stage reached).",
|
|
98
|
-
}
|
|
99
|
-
: {
|
|
100
|
-
id: "verdict",
|
|
101
|
-
primitive: "choice",
|
|
102
|
-
instructions:
|
|
103
|
-
"state carries tailsNotComparable: one of the two check reports came back empty. There is nothing to compare here; answer from criteria alone. `identical` is FORBIDDEN here.",
|
|
104
|
-
criteria:
|
|
105
|
-
"new-failure: a materially different failure than before. progress: still red, but closer to green — the default if genuinely unsure.",
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
const compare = async (
|
|
109
|
-
previous: string,
|
|
110
|
-
current: string,
|
|
111
|
-
): Promise<kit.RetryVerdict | undefined> => {
|
|
112
|
-
const comparable = current.trim() !== "" && previous.trim() !== ""
|
|
113
|
-
const { answers, truncated } = await judge("health.judge", {
|
|
114
|
-
questions: [retryQuestion(comparable)],
|
|
115
|
-
evidence: comparable ? { current, previous } : { tailsNotComparable: "true" },
|
|
116
|
-
message: t.healthJudgeMessage(),
|
|
117
|
-
label: "Judging the retry",
|
|
118
|
-
})
|
|
119
|
-
// Evidence the budget cut can make two different reports look alike: neither sameness nor progress.
|
|
120
|
-
if (!comparable || truncated.length > 0) return undefined
|
|
121
|
-
const minP = numeric(vars.judgeIdenticalMinP, Infinity)
|
|
122
|
-
if (answered(answers.verdict, "identical", minP)) return "identical"
|
|
123
|
-
return answered(answers.verdict, "progress", minP) ? "progress" : undefined
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
export interface HealthOptions {
|
|
127
|
-
/** Fix turns already spent before this call. */
|
|
128
|
-
readonly fixesSoFar?: number
|
|
129
|
-
/** Escalation rounds shared with other callers in the same run of red checks. */
|
|
130
|
-
readonly escalations?: kit.EscalationCount
|
|
131
|
-
/** Paths removed on a green run. */
|
|
132
|
-
readonly sweepOnGreen?: readonly string[]
|
|
133
|
-
/** Runs after every fix and escalation turn. */
|
|
134
|
-
readonly guard?: Guard | undefined
|
|
135
|
-
/** Runs after the escalation's agent turn only; the human rests stay unguarded. */
|
|
136
|
-
readonly describeGuard?: Guard | undefined
|
|
137
|
-
/** `false`: `guard` skips the human rests `health.stop` and `health.exhausted`. */
|
|
138
|
-
readonly guardRests?: boolean | undefined
|
|
139
|
-
readonly rules?: string | undefined
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
// Swept only on green: an unresolved analysis survives every retry.
|
|
143
|
-
const healthSweep = (options: HealthOptions): string[] => [
|
|
144
|
-
ESCALATION,
|
|
145
|
-
...(options.sweepOnGreen ?? []),
|
|
146
|
-
]
|
|
147
|
-
|
|
148
|
-
const runSuite = (options: HealthOptions): Promise<boolean> =>
|
|
149
|
-
check("health.check", env.testCommand ?? "", {
|
|
150
|
-
report: FEEDBACK,
|
|
151
|
-
label: "Running checks",
|
|
152
|
-
sweepOnGreen: healthSweep(options),
|
|
153
|
-
})
|
|
154
|
-
|
|
155
|
-
/**
|
|
156
|
-
* Loop until the suite is green: check, and on red run `fix` — escalating
|
|
157
|
-
* first once `FIX_CAP` fixes are spent, or a judge calls the failure
|
|
158
|
-
* identical to the previous round's. A judge calling it progress at the cap
|
|
159
|
-
* buys one extra fix per run of fixes. An escalation re-runs the suite once
|
|
160
|
-
* first; a green re-run ends the loop.
|
|
161
|
-
*/
|
|
162
|
-
export const healthy = (fix: () => Promise<void>, options: HealthOptions = {}): Promise<void> =>
|
|
163
|
-
kit.healthy(guarded(fix, options.guard), {
|
|
164
|
-
check: () => runSuite(options),
|
|
165
|
-
report: FEEDBACK,
|
|
166
|
-
fixCap: FIX_CAP,
|
|
167
|
-
fixesSoFar: options.fixesSoFar ?? 0,
|
|
168
|
-
compare,
|
|
169
|
-
escalations: options.escalations ?? { rounds: 0 },
|
|
170
|
-
recheck: () =>
|
|
171
|
-
recheck("health.recheck", "health.check", { sweepOnGreen: healthSweep(options) }),
|
|
172
|
-
escalation: guardedSteps(
|
|
173
|
-
options.guard,
|
|
174
|
-
options.describeGuard,
|
|
175
|
-
options.guardRests,
|
|
176
|
-
options.rules,
|
|
177
|
-
),
|
|
178
|
-
})
|
|
179
|
-
|
|
180
|
-
/**
|
|
181
|
-
* Hold the process at `<name>.blocked` until the suite is green, re-running a
|
|
182
|
-
* red check once as `<name>.recheck` before resting. `guard` runs
|
|
183
|
-
* after each `blocked` landing only — never after a check, whose refusal would
|
|
184
|
-
* re-run the same script forever.
|
|
185
|
-
*/
|
|
186
|
-
export const gate = (name: string, message: string, guard?: Guard): Promise<void> =>
|
|
187
|
-
kit.gate(
|
|
188
|
-
name,
|
|
189
|
-
async () => (await baseline("check")) || recheck("recheck", `${name}.check`),
|
|
190
|
-
guarded(() => human("blocked", { message, label: "Baseline is red", file: FEEDBACK }), guard),
|
|
191
|
-
)
|
|
@@ -1,140 +0,0 @@
|
|
|
1
|
-
import { beforeEach, describe, expect, it, vi } from "vitest"
|
|
2
|
-
|
|
3
|
-
const state = vi.hoisted(() => ({
|
|
4
|
-
files: {} as Record<string, string | undefined>,
|
|
5
|
-
vars: { standardMinP: "0.8" } as Record<string, string>,
|
|
6
|
-
answers: {} as Record<string, { answer: string; p: number } | undefined>,
|
|
7
|
-
judged: [] as string[][],
|
|
8
|
-
written: {} as Record<string, string>,
|
|
9
|
-
}))
|
|
10
|
-
|
|
11
|
-
vi.mock("../../flows/index.js", async (original) => ({
|
|
12
|
-
...(await original<typeof import("../../flows/index.js")>()),
|
|
13
|
-
read: (path: string) => state.files[path],
|
|
14
|
-
vars: state.vars,
|
|
15
|
-
judge: async (_: string, spec: { questions: { id: string }[] }) => {
|
|
16
|
-
state.judged.push(spec.questions.map((q) => q.id))
|
|
17
|
-
return { answers: state.answers, truncated: [] }
|
|
18
|
-
},
|
|
19
|
-
run: async (_: string, script: string) => {
|
|
20
|
-
const path = /> '([^']+)'/.exec(script)?.[1] ?? ""
|
|
21
|
-
const content = /printf '%s' '((?:[^']|'\\'')*)'/.exec(script)?.[1] ?? ""
|
|
22
|
-
state.written[path] = content.replaceAll("'\\''", "'")
|
|
23
|
-
},
|
|
24
|
-
}))
|
|
25
|
-
|
|
26
|
-
import type { ReviewDecision } from "../../steering/index.js"
|
|
27
|
-
import { learnStandards } from "./learn.js"
|
|
28
|
-
|
|
29
|
-
const STANDARDS = ".gtd/STANDARDS.md"
|
|
30
|
-
|
|
31
|
-
const file = (...lines: string[]): string =>
|
|
32
|
-
[
|
|
33
|
-
"# Standards",
|
|
34
|
-
"",
|
|
35
|
-
"Kept from your review answers for every agent turn. One standard per bullet.",
|
|
36
|
-
"",
|
|
37
|
-
...lines,
|
|
38
|
-
"",
|
|
39
|
-
].join("\n")
|
|
40
|
-
|
|
41
|
-
const answer = (anchor: string, text: string): ReviewDecision => ({
|
|
42
|
-
anchor,
|
|
43
|
-
body: "",
|
|
44
|
-
choice: "answer",
|
|
45
|
-
text,
|
|
46
|
-
})
|
|
47
|
-
|
|
48
|
-
const yes = { answer: "yes", p: 0.9 }
|
|
49
|
-
const no = { answer: "no", p: 0.9 }
|
|
50
|
-
const kept = () => state.written[STANDARDS]
|
|
51
|
-
|
|
52
|
-
beforeEach(() => {
|
|
53
|
-
state.files = { ".gtd/REVIEW.md": "# Review\n" }
|
|
54
|
-
state.vars.standardMinP = "0.8"
|
|
55
|
-
state.answers = {}
|
|
56
|
-
state.judged = []
|
|
57
|
-
state.written = {}
|
|
58
|
-
})
|
|
59
|
-
|
|
60
|
-
describe("learnStandards", () => {
|
|
61
|
-
it("asks a later candidate about each earlier one, and holds a contradiction for the human", async () => {
|
|
62
|
-
state.answers = { "standard-1": yes, "standard-2": yes, "standard-2.contradicts-new-1": yes }
|
|
63
|
-
await learnStandards([answer("A", "Use classes."), answer("B", "Never use classes.")])
|
|
64
|
-
expect(state.judged[0]).toContain("standard-2.contradicts-new-1")
|
|
65
|
-
expect(kept()).toBe(
|
|
66
|
-
file(
|
|
67
|
-
"- Use classes. (re: A)",
|
|
68
|
-
"",
|
|
69
|
-
"## Undecided",
|
|
70
|
-
"",
|
|
71
|
-
"- Never use classes. (re: B)",
|
|
72
|
-
" - contradicts: Use classes. (re: A)",
|
|
73
|
-
),
|
|
74
|
-
)
|
|
75
|
-
})
|
|
76
|
-
|
|
77
|
-
it("raises a contradiction ruled out below the threshold", async () => {
|
|
78
|
-
state.files[STANDARDS] = file("- Old.")
|
|
79
|
-
state.answers = { "standard-1": yes, "standard-1.contradicts-1": { answer: "no", p: 0.5 } }
|
|
80
|
-
await learnStandards([answer("A", "New.")])
|
|
81
|
-
expect(kept()).toBe(
|
|
82
|
-
file("- Old.", "", "## Undecided", "", "- New. (re: A)", " - contradicts: Old."),
|
|
83
|
-
)
|
|
84
|
-
})
|
|
85
|
-
|
|
86
|
-
it("keeps one the judge confidently calls compatible", async () => {
|
|
87
|
-
state.files[STANDARDS] = file("- Old.")
|
|
88
|
-
state.answers = { "standard-1": yes, "standard-1.contradicts-1": no }
|
|
89
|
-
await learnStandards([answer("A", "New.")])
|
|
90
|
-
expect(kept()).toBe(file("- Old.", "- New. (re: A)"))
|
|
91
|
-
})
|
|
92
|
-
|
|
93
|
-
it("asks no judge when standardMinP is blank", async () => {
|
|
94
|
-
state.vars.standardMinP = ""
|
|
95
|
-
await learnStandards([answer("A", "New.")])
|
|
96
|
-
expect(state.judged).toEqual([])
|
|
97
|
-
expect(state.written).toEqual({})
|
|
98
|
-
})
|
|
99
|
-
|
|
100
|
-
it("drops a replaced standard from another open conflict", async () => {
|
|
101
|
-
state.files[STANDARDS] = file(
|
|
102
|
-
"- O.",
|
|
103
|
-
"",
|
|
104
|
-
"## Undecided",
|
|
105
|
-
"",
|
|
106
|
-
"- A.",
|
|
107
|
-
" - contradicts: O.",
|
|
108
|
-
"- B.",
|
|
109
|
-
" - contradicts: O.",
|
|
110
|
-
)
|
|
111
|
-
// The conflict note's title hashes its new standard, "A.".
|
|
112
|
-
await learnStandards([
|
|
113
|
-
{ anchor: "Standard conflict 01248e26", body: "", choice: "fix", text: "" },
|
|
114
|
-
])
|
|
115
|
-
expect(kept()).toBe(file("- A.", "", "## Undecided", "", "- B."))
|
|
116
|
-
})
|
|
117
|
-
})
|
|
118
|
-
|
|
119
|
-
describe("learnStandards, from a won't-fix", () => {
|
|
120
|
-
it("replaces an old standard hand-edited in case only", async () => {
|
|
121
|
-
state.files[STANDARDS] = file(
|
|
122
|
-
"- use classes.",
|
|
123
|
-
"",
|
|
124
|
-
"## Undecided",
|
|
125
|
-
"",
|
|
126
|
-
"- A.",
|
|
127
|
-
" - contradicts: Use classes.",
|
|
128
|
-
)
|
|
129
|
-
await learnStandards([
|
|
130
|
-
{ anchor: "Standard conflict 01248e26", body: "", choice: "fix", text: "" },
|
|
131
|
-
])
|
|
132
|
-
expect(kept()).toBe(file("- A."))
|
|
133
|
-
})
|
|
134
|
-
|
|
135
|
-
it("does not learn again what a formatter rewrote", async () => {
|
|
136
|
-
state.files[STANDARDS] = file("- Prefer _named_ exports. (re: A)")
|
|
137
|
-
await learnStandards([answer("A", "Prefer *named* exports.")])
|
|
138
|
-
expect(state.judged).toEqual([])
|
|
139
|
-
})
|
|
140
|
-
})
|
|
@@ -1,201 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
answered,
|
|
3
|
-
changesSince,
|
|
4
|
-
judge,
|
|
5
|
-
numeric,
|
|
6
|
-
read,
|
|
7
|
-
run,
|
|
8
|
-
vars,
|
|
9
|
-
writeScript,
|
|
10
|
-
type JudgeQuestion,
|
|
11
|
-
} from "../../flows/index.js"
|
|
12
|
-
import type { ReviewDecision } from "../../steering/index.js"
|
|
13
|
-
import {
|
|
14
|
-
conflictBody,
|
|
15
|
-
conflictFor,
|
|
16
|
-
conflictTitle,
|
|
17
|
-
isConflictTitle,
|
|
18
|
-
oneLine,
|
|
19
|
-
parseStandards,
|
|
20
|
-
standardKey,
|
|
21
|
-
renderStandards,
|
|
22
|
-
STANDARDS,
|
|
23
|
-
type Conflict,
|
|
24
|
-
type Standards,
|
|
25
|
-
} from "./standards.js"
|
|
26
|
-
import { REVIEW } from "./steps.js"
|
|
27
|
-
|
|
28
|
-
interface Candidate {
|
|
29
|
-
readonly decision: ReviewDecision
|
|
30
|
-
readonly text: string
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
const SUMMARY_MAX = 200
|
|
34
|
-
|
|
35
|
-
const summary = (body: string): string => {
|
|
36
|
-
const text = oneLine(body)
|
|
37
|
-
return text.length > SUMMARY_MAX ? `${text.slice(0, SUMMARY_MAX - 1)}…` : text
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
/** The standard a won't-fix or an own answer would make; a fix states none. */
|
|
41
|
-
const candidateOf = (d: ReviewDecision): Candidate[] => {
|
|
42
|
-
const anchor = oneLine(d.anchor)
|
|
43
|
-
if (d.choice === "wontfix") {
|
|
44
|
-
const why = summary(d.body)
|
|
45
|
-
return [{ decision: d, text: `Won't fix: ${anchor}${why === "" ? "" : ` — ${why}`}` }]
|
|
46
|
-
}
|
|
47
|
-
if (d.choice === "answer") return [{ decision: d, text: `${oneLine(d.text)} (re: ${anchor})` }]
|
|
48
|
-
return []
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
// By key, as everywhere else: a hand-edit to case or emphasis still names the same standard.
|
|
52
|
-
const without = (list: readonly string[], drop: readonly string[]): string[] => {
|
|
53
|
-
const keys = new Set(drop.map(standardKey))
|
|
54
|
-
return list.filter((s) => !keys.has(standardKey(s)))
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
const adding = (list: readonly string[], text: string): string[] =>
|
|
58
|
-
list.some((s) => standardKey(s) === standardKey(text)) ? [...list] : [...list, text]
|
|
59
|
-
|
|
60
|
-
/**
|
|
61
|
-
* Keep the new one, keep the old one, or replace both with the human's own
|
|
62
|
-
* wording. Another open conflict stops naming a replaced standard: the human
|
|
63
|
-
* never judged it against the replacement, so keeping it must not drop that.
|
|
64
|
-
*/
|
|
65
|
-
const resolve = (standards: Standards, d: ReviewDecision): Standards => {
|
|
66
|
-
const c = conflictFor(standards, d.anchor)
|
|
67
|
-
if (c === undefined) return standards
|
|
68
|
-
const others = standards.undecided.filter((u) => u !== c)
|
|
69
|
-
if (d.choice === "wontfix") return { decided: standards.decided, undecided: others }
|
|
70
|
-
const kept = d.choice === "fix" ? c.text : oneLine(d.text)
|
|
71
|
-
const undecided = others.map((u) => ({ text: u.text, olds: without(u.olds, c.olds) }))
|
|
72
|
-
return { decided: adding(without(standards.decided, c.olds), kept), undecided }
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
const contradicts = (i: number, id: string, other: string): JudgeQuestion => ({
|
|
76
|
-
id: `standard-${i}.contradicts-${id}`,
|
|
77
|
-
primitive: "noul",
|
|
78
|
-
instructions: `Does the "Standard:" line of evidence "standard-${i}" contradict ${other}?`,
|
|
79
|
-
criteria:
|
|
80
|
-
"yes: following one breaks the other. no: both can be followed — they are compatible or unrelated.",
|
|
81
|
-
})
|
|
82
|
-
|
|
83
|
-
// Each candidate against every kept standard and every candidate before it.
|
|
84
|
-
const questionsFor = (i: number, anchor: string, decided: readonly string[]): JudgeQuestion[] => [
|
|
85
|
-
{
|
|
86
|
-
id: `standard-${i}`,
|
|
87
|
-
primitive: "noul",
|
|
88
|
-
instructions: `Is the human's answer to review note "${anchor}" (evidence "standard-${i}") a reusable standard for this codebase — a rule later changes and reviews should follow — rather than a call about this one note? Its "Standard:" line is the wording gtd would keep.`,
|
|
89
|
-
criteria:
|
|
90
|
-
"yes: it states a rule or preference that applies beyond this note. no: it is specific to this note, a one-off call, or unclear.",
|
|
91
|
-
},
|
|
92
|
-
...decided.map((_, k) =>
|
|
93
|
-
contradicts(i, `${k + 1}`, `kept standard ${k + 1} (evidence "kept-${k + 1}")`),
|
|
94
|
-
),
|
|
95
|
-
...Array.from({ length: i - 1 }, (_, j) =>
|
|
96
|
-
contradicts(i, `new-${j + 1}`, `the "Standard:" line of evidence "standard-${j + 1}"`),
|
|
97
|
-
),
|
|
98
|
-
]
|
|
99
|
-
|
|
100
|
-
const evidenceOf = (c: Candidate): string =>
|
|
101
|
-
[
|
|
102
|
-
`Note: ${c.decision.anchor}`,
|
|
103
|
-
`Reviewer's text: ${c.decision.body}`,
|
|
104
|
-
`Human's answer: ${c.decision.choice === "wontfix" ? "won't fix" : c.decision.text}`,
|
|
105
|
-
`Standard: ${c.text}`,
|
|
106
|
-
].join("\n")
|
|
107
|
-
|
|
108
|
-
/**
|
|
109
|
-
* One judge rest over every candidate. Only a confident `yes` on uncut
|
|
110
|
-
* evidence makes a standard; a contradiction the judge does not confidently
|
|
111
|
-
* rule out goes to the human instead of overwriting what they decided before.
|
|
112
|
-
* A cut kept standard leaves the round unlearned: raise `judgeBudgetBytes`.
|
|
113
|
-
*/
|
|
114
|
-
const judged = async (
|
|
115
|
-
candidates: readonly Candidate[],
|
|
116
|
-
decided: readonly string[],
|
|
117
|
-
minP: number,
|
|
118
|
-
): Promise<{ added: string[]; raised: Conflict[] }> => {
|
|
119
|
-
const { answers, truncated } = await judge("review.standards.judge", {
|
|
120
|
-
questions: candidates.flatMap((c, i) => questionsFor(i + 1, c.decision.anchor, decided)),
|
|
121
|
-
evidence: Object.fromEntries([
|
|
122
|
-
...candidates.map((c, i) => [`standard-${i + 1}`, evidenceOf(c)]),
|
|
123
|
-
...decided.map((s, k) => [`kept-${k + 1}`, s]),
|
|
124
|
-
]),
|
|
125
|
-
message:
|
|
126
|
-
"Judging whether your answers are standards to keep: run `gtd judge run`, or land without a verdict, which keeps none.",
|
|
127
|
-
label: "Learning your standards",
|
|
128
|
-
})
|
|
129
|
-
const ruledOut = (i: number, id: string): boolean =>
|
|
130
|
-
answered(answers[`standard-${i}.contradicts-${id}`], "no", minP)
|
|
131
|
-
const added: { i: number; text: string }[] = []
|
|
132
|
-
const raised: Conflict[] = []
|
|
133
|
-
candidates.forEach((c, n) => {
|
|
134
|
-
const i = n + 1
|
|
135
|
-
if (truncated.includes(`standard-${i}`)) return
|
|
136
|
-
if (decided.some((_, k) => truncated.includes(`kept-${k + 1}`))) return
|
|
137
|
-
if (!answered(answers[`standard-${i}`], "yes", minP)) return
|
|
138
|
-
const olds = [
|
|
139
|
-
...decided.filter((_, k) => !ruledOut(i, `${k + 1}`)),
|
|
140
|
-
...added.filter((a) => !ruledOut(i, `new-${a.i}`)).map((a) => a.text),
|
|
141
|
-
]
|
|
142
|
-
if (olds.length === 0) added.push({ i, text: c.text })
|
|
143
|
-
else raised.push({ text: c.text, olds })
|
|
144
|
-
})
|
|
145
|
-
return { added: added.map((a) => a.text), raised }
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
/**
|
|
149
|
-
* Settles every answer to a standard conflict, then learns standards from
|
|
150
|
-
* the won't-fix ticks and own answers: kept in `.gtd/STANDARDS.md`, a
|
|
151
|
-
* contradicting one as a new conflict note in `.gtd/REVIEW.md`.
|
|
152
|
-
*/
|
|
153
|
-
export const learnStandards = async (decisions: readonly ReviewDecision[]): Promise<void> => {
|
|
154
|
-
const before = parseStandards(read(STANDARDS) ?? "")
|
|
155
|
-
const rest = decisions.filter((d) => !isConflictTitle(d.anchor))
|
|
156
|
-
const settled = decisions.filter((d) => isConflictTitle(d.anchor)).reduce(resolve, before)
|
|
157
|
-
const known = new Set(
|
|
158
|
-
[...settled.decided, ...settled.undecided.map((c) => c.text)].map(standardKey),
|
|
159
|
-
)
|
|
160
|
-
const candidates = rest.flatMap(candidateOf).filter((c) => {
|
|
161
|
-
const key = standardKey(c.text)
|
|
162
|
-
if (known.has(key)) return false
|
|
163
|
-
known.add(key)
|
|
164
|
-
return true
|
|
165
|
-
})
|
|
166
|
-
// Blank keeps none, so there is nothing to ask.
|
|
167
|
-
const minP = numeric(vars.standardMinP, Infinity)
|
|
168
|
-
const { added, raised } =
|
|
169
|
-
candidates.length === 0 || !Number.isFinite(minP)
|
|
170
|
-
? { added: [], raised: [] }
|
|
171
|
-
: await judged(candidates, settled.decided, minP)
|
|
172
|
-
const after: Standards = {
|
|
173
|
-
decided: [...settled.decided, ...added],
|
|
174
|
-
undecided: [...settled.undecided, ...raised],
|
|
175
|
-
}
|
|
176
|
-
if (renderStandards(after) !== renderStandards(before)) {
|
|
177
|
-
await run("review.standards.recording", writeScript(STANDARDS, renderStandards(after)), {
|
|
178
|
-
label: "Recording your standards",
|
|
179
|
-
})
|
|
180
|
-
}
|
|
181
|
-
if (raised.length > 0) {
|
|
182
|
-
const chunks = raised.map((c) => `## ${conflictTitle(c)}\n\n${conflictBody(c)}`)
|
|
183
|
-
await run(
|
|
184
|
-
"review.standards.raising",
|
|
185
|
-
writeScript(REVIEW, `${(read(REVIEW) ?? "").trimEnd()}\n\n${chunks.join("\n\n")}\n`),
|
|
186
|
-
{ label: "Asking about a contradicting standard", file: REVIEW },
|
|
187
|
-
)
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
export const settlesStandard = (d: ReviewDecision): boolean => isConflictTitle(d.anchor)
|
|
192
|
-
|
|
193
|
-
export const standardsPending = (): boolean =>
|
|
194
|
-
parseStandards(read(STANDARDS) ?? "").undecided.length > 0
|
|
195
|
-
|
|
196
|
-
export const standardsAddedSince = (base: string): readonly string[] => {
|
|
197
|
-
const change = changesSince(base).get(STANDARDS)
|
|
198
|
-
if (change === undefined) return []
|
|
199
|
-
const was = new Set(parseStandards(change.before ?? "").decided)
|
|
200
|
-
return parseStandards(change.after ?? "").decided.filter((s) => !was.has(s))
|
|
201
|
-
}
|