@pmelab/gtd 44.0.0 → 45.0.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/.claude-plugin/plugin.json +1 -1
- package/README.md +16 -0
- package/dist/chunks/{reserved-DJk_6-ju.mjs → reserved-ty1mtlnS.mjs} +115 -3
- package/dist/chunks/{review-X7Jfz0dY.mjs → review-BmoH-9im.mjs} +163 -4
- package/dist/chunks/{tail-5w5mxGTq.mjs → tail-CM3sVoAb.mjs} +1 -1
- package/dist/workflow-kit.mjs +22 -10
- package/dist/workflows/cleanup.mjs +2 -2
- package/dist/workflows/feature.mjs +3 -3
- package/dist/workflows/fix.mjs +2 -2
- package/dist/workflows/review.mjs +3 -3
- package/dist/workflows/rollout.mjs +1 -1
- package/dist/workflows.mjs +305 -22
- package/package.json +1 -1
- package/src/workflow-kit/review.test.ts +80 -2
- package/src/workflow-kit/review.ts +45 -11
- package/src/workflows/feature/review-answers.feature +12 -3
- package/src/workflows/feature/standards.feature +286 -0
- package/src/workflows/review/review-door.feature +11 -2
- package/src/workflows/shared/learn.test.ts +154 -0
- package/src/workflows/shared/learn.ts +206 -0
- package/src/workflows/shared/options.ts +1 -0
- package/src/workflows/shared/review.ts +10 -0
- package/src/workflows/shared/standards.test.ts +90 -0
- package/src/workflows/shared/standards.ts +156 -0
- package/src/workflows/shared/text.test.ts +58 -1
- package/src/workflows/shared/text.ts +28 -1
- package/src/workflows/shared/vars.ts +2 -0
|
@@ -0,0 +1,206 @@
|
|
|
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
|
+
// The banners composedReview opens a note with say nothing about the finding; the
|
|
36
|
+
// parsed body has its paragraphs joined, so they are matched by their own text.
|
|
37
|
+
const BANNER =
|
|
38
|
+
/^_(?:The fixer left this unresolved\.|A fix turn removed this while renaming another finding — check whether it was fixed\.|No detail given\.)_\s*/
|
|
39
|
+
|
|
40
|
+
const summary = (body: string): string => {
|
|
41
|
+
const text = oneLine(body).replace(BANNER, "")
|
|
42
|
+
return text.length > SUMMARY_MAX ? `${text.slice(0, SUMMARY_MAX - 1)}…` : text
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The standard a won't-fix or an own answer would make; a fix states none. */
|
|
46
|
+
const candidateOf = (d: ReviewDecision): Candidate[] => {
|
|
47
|
+
const anchor = oneLine(d.anchor)
|
|
48
|
+
if (d.choice === "wontfix") {
|
|
49
|
+
const why = summary(d.body)
|
|
50
|
+
return [{ decision: d, text: `Won't fix: ${anchor}${why === "" ? "" : ` — ${why}`}` }]
|
|
51
|
+
}
|
|
52
|
+
if (d.choice === "answer") return [{ decision: d, text: `${oneLine(d.text)} (re: ${anchor})` }]
|
|
53
|
+
return []
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// By key, as everywhere else: a hand-edit to case or emphasis still names the same standard.
|
|
57
|
+
const without = (list: readonly string[], drop: readonly string[]): string[] => {
|
|
58
|
+
const keys = new Set(drop.map(standardKey))
|
|
59
|
+
return list.filter((s) => !keys.has(standardKey(s)))
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const adding = (list: readonly string[], text: string): string[] =>
|
|
63
|
+
list.some((s) => standardKey(s) === standardKey(text)) ? [...list] : [...list, text]
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Keep the new one, keep the old one, or replace both with the human's own
|
|
67
|
+
* wording. Another open conflict stops naming a replaced standard: the human
|
|
68
|
+
* never judged it against the replacement, so keeping it must not drop that.
|
|
69
|
+
*/
|
|
70
|
+
const resolve = (standards: Standards, d: ReviewDecision): Standards => {
|
|
71
|
+
const c = conflictFor(standards, d.anchor)
|
|
72
|
+
if (c === undefined) return standards
|
|
73
|
+
const others = standards.undecided.filter((u) => u !== c)
|
|
74
|
+
if (d.choice === "wontfix") return { decided: standards.decided, undecided: others }
|
|
75
|
+
const kept = d.choice === "fix" ? c.text : oneLine(d.text)
|
|
76
|
+
const undecided = others.map((u) => ({ text: u.text, olds: without(u.olds, c.olds) }))
|
|
77
|
+
return { decided: adding(without(standards.decided, c.olds), kept), undecided }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const contradicts = (i: number, id: string, other: string): JudgeQuestion => ({
|
|
81
|
+
id: `standard-${i}.contradicts-${id}`,
|
|
82
|
+
primitive: "noul",
|
|
83
|
+
instructions: `Does the "Standard:" line of evidence "standard-${i}" contradict ${other}?`,
|
|
84
|
+
criteria:
|
|
85
|
+
"yes: following one breaks the other. no: both can be followed — they are compatible or unrelated.",
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
// Each candidate against every kept standard and every candidate before it.
|
|
89
|
+
const questionsFor = (i: number, anchor: string, decided: readonly string[]): JudgeQuestion[] => [
|
|
90
|
+
{
|
|
91
|
+
id: `standard-${i}`,
|
|
92
|
+
primitive: "noul",
|
|
93
|
+
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.`,
|
|
94
|
+
criteria:
|
|
95
|
+
"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.",
|
|
96
|
+
},
|
|
97
|
+
...decided.map((_, k) =>
|
|
98
|
+
contradicts(i, `${k + 1}`, `kept standard ${k + 1} (evidence "kept-${k + 1}")`),
|
|
99
|
+
),
|
|
100
|
+
...Array.from({ length: i - 1 }, (_, j) =>
|
|
101
|
+
contradicts(i, `new-${j + 1}`, `the "Standard:" line of evidence "standard-${j + 1}"`),
|
|
102
|
+
),
|
|
103
|
+
]
|
|
104
|
+
|
|
105
|
+
const evidenceOf = (c: Candidate): string =>
|
|
106
|
+
[
|
|
107
|
+
`Note: ${c.decision.anchor}`,
|
|
108
|
+
`Reviewer's text: ${c.decision.body}`,
|
|
109
|
+
`Human's answer: ${c.decision.choice === "wontfix" ? "won't fix" : c.decision.text}`,
|
|
110
|
+
`Standard: ${c.text}`,
|
|
111
|
+
].join("\n")
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* One judge rest over every candidate. Only a confident `yes` on uncut
|
|
115
|
+
* evidence makes a standard; a contradiction the judge does not confidently
|
|
116
|
+
* rule out goes to the human instead of overwriting what they decided before.
|
|
117
|
+
* A cut kept standard leaves the round unlearned: raise `judgeBudgetBytes`.
|
|
118
|
+
*/
|
|
119
|
+
const judged = async (
|
|
120
|
+
candidates: readonly Candidate[],
|
|
121
|
+
decided: readonly string[],
|
|
122
|
+
minP: number,
|
|
123
|
+
): Promise<{ added: string[]; raised: Conflict[] }> => {
|
|
124
|
+
const { answers, truncated } = await judge("review.standards.judge", {
|
|
125
|
+
questions: candidates.flatMap((c, i) => questionsFor(i + 1, c.decision.anchor, decided)),
|
|
126
|
+
evidence: Object.fromEntries([
|
|
127
|
+
...candidates.map((c, i) => [`standard-${i + 1}`, evidenceOf(c)]),
|
|
128
|
+
...decided.map((s, k) => [`kept-${k + 1}`, s]),
|
|
129
|
+
]),
|
|
130
|
+
message:
|
|
131
|
+
"Judging whether your answers are standards to keep: run `gtd judge run`, or land without a verdict, which keeps none.",
|
|
132
|
+
label: "Learning your standards",
|
|
133
|
+
})
|
|
134
|
+
const ruledOut = (i: number, id: string): boolean =>
|
|
135
|
+
answered(answers[`standard-${i}.contradicts-${id}`], "no", minP)
|
|
136
|
+
const added: { i: number; text: string }[] = []
|
|
137
|
+
const raised: Conflict[] = []
|
|
138
|
+
candidates.forEach((c, n) => {
|
|
139
|
+
const i = n + 1
|
|
140
|
+
if (truncated.includes(`standard-${i}`)) return
|
|
141
|
+
if (decided.some((_, k) => truncated.includes(`kept-${k + 1}`))) return
|
|
142
|
+
if (!answered(answers[`standard-${i}`], "yes", minP)) return
|
|
143
|
+
const olds = [
|
|
144
|
+
...decided.filter((_, k) => !ruledOut(i, `${k + 1}`)),
|
|
145
|
+
...added.filter((a) => !ruledOut(i, `new-${a.i}`)).map((a) => a.text),
|
|
146
|
+
]
|
|
147
|
+
if (olds.length === 0) added.push({ i, text: c.text })
|
|
148
|
+
else raised.push({ text: c.text, olds })
|
|
149
|
+
})
|
|
150
|
+
return { added: added.map((a) => a.text), raised }
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Settles every answer to a standard conflict, then learns standards from
|
|
155
|
+
* the won't-fix ticks and own answers: kept in `.gtd/STANDARDS.md`, a
|
|
156
|
+
* contradicting one as a new conflict note in `.gtd/REVIEW.md`.
|
|
157
|
+
*/
|
|
158
|
+
export const learnStandards = async (decisions: readonly ReviewDecision[]): Promise<void> => {
|
|
159
|
+
const before = parseStandards(read(STANDARDS) ?? "")
|
|
160
|
+
const rest = decisions.filter((d) => !isConflictTitle(d.anchor))
|
|
161
|
+
const settled = decisions.filter((d) => isConflictTitle(d.anchor)).reduce(resolve, before)
|
|
162
|
+
const known = new Set(
|
|
163
|
+
[...settled.decided, ...settled.undecided.map((c) => c.text)].map(standardKey),
|
|
164
|
+
)
|
|
165
|
+
const candidates = rest.flatMap(candidateOf).filter((c) => {
|
|
166
|
+
const key = standardKey(c.text)
|
|
167
|
+
if (known.has(key)) return false
|
|
168
|
+
known.add(key)
|
|
169
|
+
return true
|
|
170
|
+
})
|
|
171
|
+
// Blank keeps none, so there is nothing to ask.
|
|
172
|
+
const minP = numeric(vars.standardMinP, Infinity)
|
|
173
|
+
const { added, raised } =
|
|
174
|
+
candidates.length === 0 || !Number.isFinite(minP)
|
|
175
|
+
? { added: [], raised: [] }
|
|
176
|
+
: await judged(candidates, settled.decided, minP)
|
|
177
|
+
const after: Standards = {
|
|
178
|
+
decided: [...settled.decided, ...added],
|
|
179
|
+
undecided: [...settled.undecided, ...raised],
|
|
180
|
+
}
|
|
181
|
+
if (renderStandards(after) !== renderStandards(before)) {
|
|
182
|
+
await run("review.standards.recording", writeScript(STANDARDS, renderStandards(after)), {
|
|
183
|
+
label: "Recording your standards",
|
|
184
|
+
})
|
|
185
|
+
}
|
|
186
|
+
if (raised.length > 0) {
|
|
187
|
+
const chunks = raised.map((c) => `## ${conflictTitle(c)}\n\n${conflictBody(c)}`)
|
|
188
|
+
await run(
|
|
189
|
+
"review.standards.raising",
|
|
190
|
+
writeScript(REVIEW, `${(read(REVIEW) ?? "").trimEnd()}\n\n${chunks.join("\n\n")}\n`),
|
|
191
|
+
{ label: "Asking about a contradicting standard", file: REVIEW },
|
|
192
|
+
)
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export const settlesStandard = (d: ReviewDecision): boolean => isConflictTitle(d.anchor)
|
|
197
|
+
|
|
198
|
+
export const standardsPending = (): boolean =>
|
|
199
|
+
parseStandards(read(STANDARDS) ?? "").undecided.length > 0
|
|
200
|
+
|
|
201
|
+
export const standardsAddedSince = (base: string): readonly string[] => {
|
|
202
|
+
const change = changesSince(base).get(STANDARDS)
|
|
203
|
+
if (change === undefined) return []
|
|
204
|
+
const was = new Set(parseStandards(change.before ?? "").decided)
|
|
205
|
+
return parseStandards(change.after ?? "").decided.filter((s) => !was.has(s))
|
|
206
|
+
}
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
head,
|
|
5
5
|
judge,
|
|
6
6
|
numeric,
|
|
7
|
+
read,
|
|
7
8
|
requireRevert,
|
|
8
9
|
restoreScript,
|
|
9
10
|
run,
|
|
@@ -30,6 +31,8 @@ import {
|
|
|
30
31
|
REVIEW,
|
|
31
32
|
reviewMissing,
|
|
32
33
|
} from "./steps.js"
|
|
34
|
+
import { learnStandards, settlesStandard, standardsAddedSince, standardsPending } from "./learn.js"
|
|
35
|
+
import { parseStandards, STANDARDS } from "./standards.js"
|
|
33
36
|
import { closeReview, isOptionalObject, tail, type TailOptions } from "./tail.js"
|
|
34
37
|
import * as t from "./text.js"
|
|
35
38
|
|
|
@@ -191,6 +194,10 @@ const composing = (
|
|
|
191
194
|
weakened: kit.removedAssertions(changesSince(base)),
|
|
192
195
|
nitFix,
|
|
193
196
|
answered,
|
|
197
|
+
standards: {
|
|
198
|
+
added: standardsAddedSince(base),
|
|
199
|
+
undecided: parseStandards(read(STANDARDS) ?? "").undecided,
|
|
200
|
+
},
|
|
194
201
|
}),
|
|
195
202
|
[QUALITY],
|
|
196
203
|
)
|
|
@@ -239,6 +246,9 @@ export const humanReview = (
|
|
|
239
246
|
collecting: feedback.collecting,
|
|
240
247
|
triage: triage(severities),
|
|
241
248
|
decided: (decisions) => decisions.forEach((d) => answered.add(d.anchor)),
|
|
249
|
+
settles: settlesStandard,
|
|
250
|
+
learn: learnStandards,
|
|
251
|
+
pending: standardsPending,
|
|
242
252
|
answerQuestions: answerReviewQuestions,
|
|
243
253
|
fixNits: async (notes) => {
|
|
244
254
|
await guarded((n: typeof notes) => fixNits(n, fixAccess, rules), guard)(notes)
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import {
|
|
3
|
+
conflictFor,
|
|
4
|
+
conflictTitle,
|
|
5
|
+
parseStandards,
|
|
6
|
+
renderStandards,
|
|
7
|
+
standardsBlock,
|
|
8
|
+
} from "./standards.js"
|
|
9
|
+
|
|
10
|
+
const conflict = { text: "Calculators are functions.", olds: ["Calculators are classes."] }
|
|
11
|
+
|
|
12
|
+
describe("standards file", () => {
|
|
13
|
+
it("round-trips decided and undecided standards", () => {
|
|
14
|
+
const standards = { decided: ["Prefer early returns."], undecided: [conflict] }
|
|
15
|
+
expect(parseStandards(renderStandards(standards))).toEqual(standards)
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it("reads a hand-edited file: any heading groups decided ones, wrapped lines join", () => {
|
|
19
|
+
const text = [
|
|
20
|
+
"# Standards",
|
|
21
|
+
"",
|
|
22
|
+
"Some prose.",
|
|
23
|
+
"",
|
|
24
|
+
"## Testing",
|
|
25
|
+
"",
|
|
26
|
+
"1. Every bug fix",
|
|
27
|
+
" gets a test.",
|
|
28
|
+
"",
|
|
29
|
+
"## Undecided",
|
|
30
|
+
"",
|
|
31
|
+
"- New",
|
|
32
|
+
" - contradicts: Old",
|
|
33
|
+
].join("\n")
|
|
34
|
+
expect(parseStandards(text)).toEqual({
|
|
35
|
+
decided: ["Every bug fix gets a test."],
|
|
36
|
+
undecided: [{ text: "New", olds: ["Old"] }],
|
|
37
|
+
})
|
|
38
|
+
})
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
describe("a formatter-wrapped file", () => {
|
|
42
|
+
it("keeps a wrapped contradicts line with its old standard", () => {
|
|
43
|
+
const text = [
|
|
44
|
+
"## Undecided",
|
|
45
|
+
"",
|
|
46
|
+
"- Calculators are plain functions. (re: subtract has no class)",
|
|
47
|
+
" - contradicts: Won't fix: subtract has no class — Calculators read better as",
|
|
48
|
+
" classes.",
|
|
49
|
+
].join("\n")
|
|
50
|
+
expect(parseStandards(text).undecided).toEqual([
|
|
51
|
+
{
|
|
52
|
+
text: "Calculators are plain functions. (re: subtract has no class)",
|
|
53
|
+
olds: ["Won't fix: subtract has no class — Calculators read better as classes."],
|
|
54
|
+
},
|
|
55
|
+
])
|
|
56
|
+
})
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
describe("conflict notes", () => {
|
|
60
|
+
it("keep their title when a formatter rewrites the emphasis", () => {
|
|
61
|
+
const standards = { decided: [], undecided: [{ text: "Use _named_ exports.", olds: [] }] }
|
|
62
|
+
expect(
|
|
63
|
+
conflictFor(standards, conflictTitle({ text: "Use *named* exports.", olds: [] })),
|
|
64
|
+
).toBeDefined()
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it("are one per standard, however often the file lists it", () => {
|
|
68
|
+
const text = "## Undecided\n\n- New\n - contradicts: Old\n- New\n - contradicts: Old\n"
|
|
69
|
+
expect(parseStandards(text).undecided).toHaveLength(1)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it("title back to the conflict it asks about", () => {
|
|
73
|
+
const standards = { decided: [], undecided: [conflict] }
|
|
74
|
+
expect(conflictFor(standards, conflictTitle(conflict))).toBe(conflict)
|
|
75
|
+
expect(conflictFor(standards, "Standard conflict 00000000")).toBeUndefined()
|
|
76
|
+
})
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
describe("standardsBlock", () => {
|
|
80
|
+
it("is empty without decided standards", () => {
|
|
81
|
+
expect(standardsBlock(undefined)).toBe("")
|
|
82
|
+
expect(standardsBlock(renderStandards({ decided: [], undecided: [conflict] }))).toBe("")
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it("lists only the decided ones", () => {
|
|
86
|
+
const block = standardsBlock(renderStandards({ decided: ["A."], undecided: [conflict] }))
|
|
87
|
+
expect(block).toContain("- A.")
|
|
88
|
+
expect(block).not.toContain(conflict.text)
|
|
89
|
+
})
|
|
90
|
+
})
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { ANSWER_LEAD, FREE_TEXT_PLACEHOLDER } from "../../steering/index.js"
|
|
2
|
+
|
|
3
|
+
export const STANDARDS = ".gtd/STANDARDS.md"
|
|
4
|
+
|
|
5
|
+
/** A learned standard that contradicts decided ones, waiting on the human. */
|
|
6
|
+
export interface Conflict {
|
|
7
|
+
readonly text: string
|
|
8
|
+
/** The decided standards it contradicts. */
|
|
9
|
+
readonly olds: readonly string[]
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface Standards {
|
|
13
|
+
readonly decided: readonly string[]
|
|
14
|
+
readonly undecided: readonly Conflict[]
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const UNDECIDED = "Undecided"
|
|
18
|
+
|
|
19
|
+
// The same class as the review's `Gtd-Mark` anchors, so a title reads alike in both.
|
|
20
|
+
export const oneLine = (text: string): string =>
|
|
21
|
+
// eslint-disable-next-line no-control-regex -- the control range IS the point
|
|
22
|
+
text.replace(/[\s\x00-\x1f\x7f-\x9f\u2028\u2029]+/g, " ").trim()
|
|
23
|
+
|
|
24
|
+
const ITEM = /^(?:[-*+]|\d+[.)])\s+(.*)$/
|
|
25
|
+
const CONTRADICTS = /^\s+[-*+]\s+contradicts:\s*(.*)$/i
|
|
26
|
+
|
|
27
|
+
interface Item {
|
|
28
|
+
text: string
|
|
29
|
+
/** Set under `## Undecided`. */
|
|
30
|
+
olds?: string[]
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// A formatter wraps long lines: the tail belongs to the last `contradicts:` once there is one.
|
|
34
|
+
const wrapped = (item: Item, line: string): void => {
|
|
35
|
+
const olds = item.olds
|
|
36
|
+
if (olds !== undefined && olds.length > 0) olds[olds.length - 1] += ` ${line.trim()}`
|
|
37
|
+
else item.text += ` ${line.trim()}`
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** What tells two standards apart: not the emphasis or spacing a formatter may rewrite. */
|
|
41
|
+
export const standardKey = (text: string): string =>
|
|
42
|
+
oneLine(text.replace(/[*_`\\]/g, "")).toLowerCase()
|
|
43
|
+
|
|
44
|
+
// Each standard once, so every conflict note gets a title of its own.
|
|
45
|
+
const toStandards = (items: readonly Item[]): Standards => {
|
|
46
|
+
const seen = new Set<string>()
|
|
47
|
+
const fresh = (text: string): boolean => {
|
|
48
|
+
const key = standardKey(text)
|
|
49
|
+
if (key === "" || seen.has(key)) return false
|
|
50
|
+
seen.add(key)
|
|
51
|
+
return true
|
|
52
|
+
}
|
|
53
|
+
const decided = items.filter((i) => i.olds === undefined).map((i) => oneLine(i.text))
|
|
54
|
+
const undecided = items.flatMap((i) =>
|
|
55
|
+
i.olds === undefined
|
|
56
|
+
? []
|
|
57
|
+
: [{ text: oneLine(i.text), olds: i.olds.map(oneLine).filter((o) => o !== "") }],
|
|
58
|
+
)
|
|
59
|
+
return { decided: decided.filter(fresh), undecided: undecided.filter((c) => fresh(c.text)) }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Every top-level bullet is one standard, those under `## Undecided` waiting
|
|
64
|
+
* on the human; a hand-edited file may group the rest under any heading.
|
|
65
|
+
*/
|
|
66
|
+
export const parseStandards = (text: string): Standards => {
|
|
67
|
+
const items: Item[] = []
|
|
68
|
+
let pending = false
|
|
69
|
+
let last: Item | undefined
|
|
70
|
+
for (const line of text.split("\n")) {
|
|
71
|
+
const heading = /^(#{1,6})\s+(.*)$/.exec(line)
|
|
72
|
+
const item = ITEM.exec(line)
|
|
73
|
+
const old = CONTRADICTS.exec(line)
|
|
74
|
+
if (heading !== null) {
|
|
75
|
+
pending = heading[1]!.length > 1 && heading[2]!.trim() === UNDECIDED
|
|
76
|
+
last = undefined
|
|
77
|
+
} else if (item !== null) {
|
|
78
|
+
last = pending ? { text: item[1]!, olds: [] } : { text: item[1]! }
|
|
79
|
+
items.push(last)
|
|
80
|
+
} else if (old !== null && last?.olds !== undefined) last.olds.push(old[1]!)
|
|
81
|
+
else if (/^\s+\S/.test(line) && last !== undefined) wrapped(last, line)
|
|
82
|
+
else if (line.trim() !== "") last = undefined
|
|
83
|
+
}
|
|
84
|
+
return toStandards(items)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export const renderStandards = ({ decided, undecided }: Standards): string => {
|
|
88
|
+
const parts = [
|
|
89
|
+
"# Standards",
|
|
90
|
+
"Kept from your review answers for every agent turn. One standard per bullet.",
|
|
91
|
+
...(decided.length === 0 ? [] : [decided.map((s) => `- ${s}`).join("\n")]),
|
|
92
|
+
...(undecided.length === 0
|
|
93
|
+
? []
|
|
94
|
+
: [
|
|
95
|
+
`## ${UNDECIDED}`,
|
|
96
|
+
undecided
|
|
97
|
+
.map((c) => [`- ${c.text}`, ...c.olds.map((o) => ` - contradicts: ${o}`)].join("\n"))
|
|
98
|
+
.join("\n"),
|
|
99
|
+
]),
|
|
100
|
+
]
|
|
101
|
+
return `${parts.join("\n\n")}\n`
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// FNV-1a over the key: a conflict's chunk title must survive markdown parsing and a
|
|
105
|
+
// formatter's rewrite of STANDARDS.md, which its text may not.
|
|
106
|
+
const hash = (raw: string): string => {
|
|
107
|
+
const text = standardKey(raw)
|
|
108
|
+
let h = 0x811c9dc5
|
|
109
|
+
for (let i = 0; i < text.length; i++) h = Math.imul(h ^ text.charCodeAt(i), 0x01000193)
|
|
110
|
+
return (h >>> 0).toString(16).padStart(8, "0")
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const CONFLICT_TITLE = /^Standard conflict ([0-9a-f]{8})$/
|
|
114
|
+
|
|
115
|
+
export const conflictTitle = (c: Conflict): string => `Standard conflict ${hash(c.text)}`
|
|
116
|
+
|
|
117
|
+
export const conflictFor = (standards: Standards, title: string): Conflict | undefined => {
|
|
118
|
+
const id = CONFLICT_TITLE.exec(title)?.[1]
|
|
119
|
+
return id === undefined ? undefined : standards.undecided.find((c) => hash(c.text) === id)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export const isConflictTitle = (title: string): boolean => CONFLICT_TITLE.test(title)
|
|
123
|
+
|
|
124
|
+
const KEEP_NEW = "keep the new one (drop the old)"
|
|
125
|
+
const KEEP_OLD = "keep the old one (discard the new)"
|
|
126
|
+
|
|
127
|
+
/** A conflict's chunk body; its three boxes read as fix / won't fix / own answer by position. */
|
|
128
|
+
export const conflictBody = (c: Conflict): string =>
|
|
129
|
+
[
|
|
130
|
+
...(c.olds.length === 0
|
|
131
|
+
? [
|
|
132
|
+
`A standard learned from your answers contradicted ones another answer since replaced in \`${STANDARDS}\`. Keep it, discard it, or reword it.`,
|
|
133
|
+
`New: ${c.text}`,
|
|
134
|
+
]
|
|
135
|
+
: [
|
|
136
|
+
`A standard learned from your answers contradicts ${c.olds.length === 1 ? "one" : "some"} already in \`${STANDARDS}\`. Your own answer replaces both with its wording.`,
|
|
137
|
+
`New: ${c.text}`,
|
|
138
|
+
`Old:\n\n${c.olds.map((o) => `- ${o}`).join("\n")}`,
|
|
139
|
+
]),
|
|
140
|
+
`${ANSWER_LEAD}\n\n- [ ] ${KEEP_NEW}\n- [ ] ${KEEP_OLD}\n- [ ] ${FREE_TEXT_PLACEHOLDER}`,
|
|
141
|
+
].join("\n\n")
|
|
142
|
+
|
|
143
|
+
/** What every agent turn is told while `.gtd/STANDARDS.md` holds decided standards. */
|
|
144
|
+
export const standardsBlock = (text: string | undefined): string => {
|
|
145
|
+
const { decided } = parseStandards(text ?? "")
|
|
146
|
+
if (decided.length === 0) return ""
|
|
147
|
+
return `## Standards
|
|
148
|
+
|
|
149
|
+
\`${STANDARDS}\` holds the standards the human set in earlier reviews.
|
|
150
|
+
Follow every one in what you write. Reviewing, never raise a finding a
|
|
151
|
+
standard rules out — a \`Won't fix:\` standard is a finding the human
|
|
152
|
+
rejected, so never raise it or one like it again — and raise breaking a
|
|
153
|
+
standard as a finding.
|
|
154
|
+
|
|
155
|
+
${decided.map((s) => `- ${s}`).join("\n")}`
|
|
156
|
+
}
|
|
@@ -31,7 +31,7 @@ import {
|
|
|
31
31
|
withSkills,
|
|
32
32
|
composedReview,
|
|
33
33
|
} from "./text.js"
|
|
34
|
-
import { steeringFormatFor, unansweredNotes } from "../../steering/index.js"
|
|
34
|
+
import { reviewDecisions, steeringFormatFor, unansweredNotes } from "../../steering/index.js"
|
|
35
35
|
import { captureStep, renderText } from "./text.fixture.js"
|
|
36
36
|
|
|
37
37
|
describe("withSkills", () => {
|
|
@@ -68,6 +68,28 @@ describe("agentWithSkills", () => {
|
|
|
68
68
|
expect(request.options).not.toHaveProperty("skills")
|
|
69
69
|
})
|
|
70
70
|
|
|
71
|
+
it("ends every prompt with the kept standards, never the undecided ones", async () => {
|
|
72
|
+
const standards = [
|
|
73
|
+
"# Standards",
|
|
74
|
+
"",
|
|
75
|
+
"- Prefer early returns.",
|
|
76
|
+
"",
|
|
77
|
+
"## Undecided",
|
|
78
|
+
"",
|
|
79
|
+
"- Use classes.",
|
|
80
|
+
" - contradicts: Prefer early returns.",
|
|
81
|
+
].join("\n")
|
|
82
|
+
const request = await captureStep(() => agentWithSkills("name", "do-the-work"), {
|
|
83
|
+
scope: "a",
|
|
84
|
+
skills: { a: [] },
|
|
85
|
+
read: (path) => (path === ".gtd/STANDARDS.md" ? standards : undefined),
|
|
86
|
+
})
|
|
87
|
+
if (request.kind !== "agent") throw new Error("unreachable")
|
|
88
|
+
expect(request.prompt.startsWith("do-the-work\n\n## Standards")).toBe(true)
|
|
89
|
+
expect(request.prompt.endsWith("- Prefer early returns.")).toBe(true)
|
|
90
|
+
expect(request.prompt).not.toContain("Use classes.")
|
|
91
|
+
})
|
|
92
|
+
|
|
71
93
|
it("leaves the prompt bare when the scope resolves to an empty list", async () => {
|
|
72
94
|
const request = await capture(() => agentWithSkills("name", "do-the-work"), { a: [] })
|
|
73
95
|
if (request.kind !== "agent") throw new Error("unreachable")
|
|
@@ -1244,6 +1266,41 @@ describe("buildReviewCollectingPrompt answer boxes", () => {
|
|
|
1244
1266
|
describe("composedReview", () => {
|
|
1245
1267
|
const BASE = "0123456789abcdef0123456789abcdef01234567"
|
|
1246
1268
|
|
|
1269
|
+
describe("standards", () => {
|
|
1270
|
+
const conflict = { text: "Calculators are functions.", olds: ["Calculators are classes."] }
|
|
1271
|
+
const withConflict = composedReview(BASE, {
|
|
1272
|
+
notes: [],
|
|
1273
|
+
fixed: [],
|
|
1274
|
+
noise: [],
|
|
1275
|
+
standards: { added: [], undecided: [conflict] },
|
|
1276
|
+
})
|
|
1277
|
+
|
|
1278
|
+
it("asks about each undecided standard in a note that blocks sign-off", () => {
|
|
1279
|
+
expect(withConflict).toMatch(/^## Standard conflict [0-9a-f]{8}$/m)
|
|
1280
|
+
expect(withConflict).toContain("- [ ] keep the new one (drop the old)")
|
|
1281
|
+
expect(unansweredNotes(withConflict)).toHaveLength(1)
|
|
1282
|
+
})
|
|
1283
|
+
|
|
1284
|
+
it("reads its boxes by position, like a note's", () => {
|
|
1285
|
+
const ticked = withConflict.replace("- [ ] keep the old one", "- [x] keep the old one")
|
|
1286
|
+
expect(unansweredNotes(ticked)).toEqual([])
|
|
1287
|
+
expect(reviewDecisions(withConflict, ticked)).toEqual([
|
|
1288
|
+
expect.objectContaining({ choice: "wontfix" }),
|
|
1289
|
+
])
|
|
1290
|
+
})
|
|
1291
|
+
|
|
1292
|
+
it("lists the standards added", () => {
|
|
1293
|
+
const doc = composedReview(BASE, {
|
|
1294
|
+
notes: [],
|
|
1295
|
+
fixed: [],
|
|
1296
|
+
noise: [],
|
|
1297
|
+
standards: { added: ["Prefer early returns."], undecided: [] },
|
|
1298
|
+
})
|
|
1299
|
+
expect(doc).toContain("## Standards added")
|
|
1300
|
+
expect(doc).toContain("- Prefer early returns.")
|
|
1301
|
+
})
|
|
1302
|
+
})
|
|
1303
|
+
|
|
1247
1304
|
it("is a valid review with unique chunk titles, so every note matches its own chunk", () => {
|
|
1248
1305
|
const doc = composedReview(BASE, {
|
|
1249
1306
|
notes: [
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
agent,
|
|
3
3
|
codeThreads,
|
|
4
|
+
read,
|
|
4
5
|
displayPath,
|
|
5
6
|
env,
|
|
6
7
|
head,
|
|
@@ -19,6 +20,13 @@ import {
|
|
|
19
20
|
FREE_TEXT_PLACEHOLDER,
|
|
20
21
|
} from "../../steering/index.js"
|
|
21
22
|
import { escapeRegExp } from "./registry.js"
|
|
23
|
+
import {
|
|
24
|
+
conflictBody,
|
|
25
|
+
conflictTitle,
|
|
26
|
+
standardsBlock,
|
|
27
|
+
STANDARDS,
|
|
28
|
+
type Conflict,
|
|
29
|
+
} from "./standards.js"
|
|
22
30
|
import { defaultFlagRegistry } from "./vars.js"
|
|
23
31
|
import type { CollectRound, RemovedAssertions, Requested } from "../../workflow-kit/index.js"
|
|
24
32
|
import {
|
|
@@ -57,7 +65,8 @@ export const agentWithSkills = (
|
|
|
57
65
|
options: AgentOptions = {},
|
|
58
66
|
): Promise<void> => {
|
|
59
67
|
const access = accessPreamble(accessFor(name, options.file))
|
|
60
|
-
const
|
|
68
|
+
const standards = standardsBlock(read(STANDARDS))
|
|
69
|
+
const body = [access, prompt, standards].filter((part) => part !== "").join("\n\n")
|
|
61
70
|
return agent(name, withSkills(skillsFor(name).join(", "), body), options)
|
|
62
71
|
}
|
|
63
72
|
|
|
@@ -124,12 +133,17 @@ export interface ComposedReview {
|
|
|
124
133
|
readonly nitFix?: string | undefined
|
|
125
134
|
/** Titles of notes the human already answered, left out. */
|
|
126
135
|
readonly answered?: ReadonlySet<string> | undefined
|
|
136
|
+
/** Standards added since the review base, and the conflicts still waiting on the human. */
|
|
137
|
+
readonly standards?:
|
|
138
|
+
| { readonly added: readonly string[]; readonly undecided: readonly Conflict[] }
|
|
139
|
+
| undefined
|
|
127
140
|
}
|
|
128
141
|
|
|
129
142
|
const SUMMARY_TITLES = [
|
|
130
143
|
"Weakened scenarios",
|
|
131
144
|
"Fixed from your notes",
|
|
132
145
|
"Fixed automatically",
|
|
146
|
+
"Standards added",
|
|
133
147
|
"Dropped as noise",
|
|
134
148
|
"Nothing to note",
|
|
135
149
|
]
|
|
@@ -157,6 +171,10 @@ export const composedReview = (base: string, report: ComposedReview): string =>
|
|
|
157
171
|
const chunk = (title: string, body: string): string => `## ${title}\n\n${body.trim()}`
|
|
158
172
|
// The lead-in line keeps a body that ends in a list from absorbing the boxes.
|
|
159
173
|
const answerBoxes = `${ANSWER_LEAD}\n\n- [ ] ${ANSWER_FIX}\n- [ ] ${ANSWER_WONT_FIX}\n- [ ] ${FREE_TEXT_PLACEHOLDER}`
|
|
174
|
+
const conflicts = (report.standards?.undecided ?? []).map((c) =>
|
|
175
|
+
chunk(unique(conflictTitle(c)), conflictBody(c)),
|
|
176
|
+
)
|
|
177
|
+
const added = report.standards?.added ?? []
|
|
160
178
|
const notes = report.notes
|
|
161
179
|
.map((n) => ({ ...n, title: unique(n.title) }))
|
|
162
180
|
.filter((n) => report.answered?.has(n.title) !== true)
|
|
@@ -180,6 +198,7 @@ export const composedReview = (base: string, report: ComposedReview): string =>
|
|
|
180
198
|
`These scenarios lost an assertion the review base had:\n\n${weakened.join("\n")}`,
|
|
181
199
|
),
|
|
182
200
|
]),
|
|
201
|
+
...conflicts,
|
|
183
202
|
...notes.map((n) =>
|
|
184
203
|
chunk(
|
|
185
204
|
n.title,
|
|
@@ -200,6 +219,14 @@ export const composedReview = (base: string, report: ComposedReview): string =>
|
|
|
200
219
|
report.fixed.map((f) => `- ${f.title} (${short(f.commit)})`).join("\n"),
|
|
201
220
|
),
|
|
202
221
|
]),
|
|
222
|
+
...(added.length === 0
|
|
223
|
+
? []
|
|
224
|
+
: [
|
|
225
|
+
chunk(
|
|
226
|
+
"Standards added",
|
|
227
|
+
`Learned from your answers and kept in \`${STANDARDS}\`:\n\n${added.map((s) => `- ${s}`).join("\n")}`,
|
|
228
|
+
),
|
|
229
|
+
]),
|
|
203
230
|
...(report.noise.length === 0
|
|
204
231
|
? []
|
|
205
232
|
: [chunk("Dropped as noise", report.noise.map((f) => `- ${f.title} (p=${f.p})`).join("\n"))]),
|
|
@@ -24,6 +24,8 @@ export const defaults: Readonly<Record<string, string>> = {
|
|
|
24
24
|
reviewNoteActionable: "0.7",
|
|
25
25
|
reviewSeverityMinP: "0.7",
|
|
26
26
|
qualityTriageMinP: "0.7",
|
|
27
|
+
// How sure the judge must be that an answer at the review gate is a reusable standard.
|
|
28
|
+
standardMinP: "0.8",
|
|
27
29
|
refactorFlagPattern: "\\bGTD_FLAG_\\w+",
|
|
28
30
|
seamAllow: [...testFileGlobs, ".gtd/**"].join(", "),
|
|
29
31
|
flagMode: "true",
|