@pmelab/gtd 43.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.
Files changed (46) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +33 -13
  3. package/dist/chunks/{reserved-COEMFEpy.mjs → reserved-ty1mtlnS.mjs} +155 -3
  4. package/dist/chunks/review-BmoH-9im.mjs +408 -0
  5. package/dist/chunks/{tail-C_xn8U7U.mjs → tail-CM3sVoAb.mjs} +3 -3
  6. package/dist/workflow-kit.mjs +22 -10
  7. package/dist/workflows/cleanup.mjs +2 -2
  8. package/dist/workflows/feature.mjs +6 -278
  9. package/dist/workflows/fix.mjs +2 -2
  10. package/dist/workflows/review.mjs +27 -7
  11. package/dist/workflows/rollout.mjs +1 -1
  12. package/dist/workflows.mjs +379 -66
  13. package/package.json +1 -1
  14. package/src/steering/FORMATS.md +4 -4
  15. package/src/workflow-kit/review.test.ts +80 -2
  16. package/src/workflow-kit/review.ts +45 -11
  17. package/src/workflows/feature/coverage.ts +1 -1
  18. package/src/workflows/feature/feature.test.ts +8 -0
  19. package/src/workflows/feature/feature.ts +2 -2
  20. package/src/workflows/feature/index.ts +1 -1
  21. package/src/workflows/feature/refactor.ts +1 -1
  22. package/src/workflows/feature/review-answers.feature +12 -3
  23. package/src/workflows/feature/seam.ts +1 -1
  24. package/src/workflows/feature/standards.feature +286 -0
  25. package/src/workflows/feature/steps.test.ts +2 -64
  26. package/src/workflows/feature/steps.ts +5 -41
  27. package/src/workflows/fix/fix.feature +20 -21
  28. package/src/workflows/index.ts +1 -1
  29. package/src/workflows/review/review-door.feature +158 -0
  30. package/src/workflows/review/review.test.ts +1 -73
  31. package/src/workflows/review/review.ts +34 -6
  32. package/src/workflows/shared/learn.test.ts +154 -0
  33. package/src/workflows/shared/learn.ts +206 -0
  34. package/src/workflows/shared/options.ts +1 -0
  35. package/src/workflows/{feature → shared}/review.test.ts +4 -8
  36. package/src/workflows/{feature → shared}/review.ts +23 -10
  37. package/src/workflows/shared/skills.test.ts +2 -0
  38. package/src/workflows/shared/skills.ts +2 -0
  39. package/src/workflows/shared/standards.test.ts +90 -0
  40. package/src/workflows/shared/standards.ts +156 -0
  41. package/src/workflows/shared/steps.test.ts +64 -2
  42. package/src/workflows/shared/steps.ts +40 -1
  43. package/src/workflows/shared/tail.ts +2 -2
  44. package/src/workflows/shared/text.test.ts +58 -1
  45. package/src/workflows/shared/text.ts +39 -1
  46. 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
+ }
@@ -20,6 +20,7 @@ export const ShippedOptions = Schema.Struct({
20
20
  reviewNoteActionable: setting,
21
21
  reviewSeverityMinP: setting,
22
22
  qualityTriageMinP: setting,
23
+ standardMinP: setting,
23
24
  refactorFlagPattern: setting,
24
25
  seamAllow: setting,
25
26
  sliceMaxCriteria: setting,
@@ -9,7 +9,7 @@ import {
9
9
  } from "../../flows/index.js"
10
10
  import {
11
11
  buildTail,
12
- review,
12
+ humanReview,
13
13
  reUnwind,
14
14
  severityOf,
15
15
  type BuildTailOptions,
@@ -17,8 +17,7 @@ import {
17
17
  } from "./review.js"
18
18
  import type { ReviewNote } from "../../steering/index.js"
19
19
  import * as bundled from "../index.js"
20
- import type * as featureEntry from "./index.js"
21
- import { fixtureContext } from "../shared/text.fixture.js"
20
+ import { fixtureContext } from "./text.fixture.js"
22
21
  import { toRequest } from "../../judges/index.js"
23
22
 
24
23
  afterEach(() => installContext(undefined))
@@ -155,7 +154,7 @@ const drive = async (d: Drive): Promise<Run> => {
155
154
  installContext(context)
156
155
  let result: ReviewOutcome | "stopped"
157
156
  try {
158
- result = await review("base", undefined, {
157
+ result = await humanReview("base", undefined, {
159
158
  ...d.options,
160
159
  ...(d.guarded ? { guard: () => void log.push("guard") } : {}),
161
160
  })
@@ -975,11 +974,8 @@ describe("BuildTailOptions.lensBase", () => {
975
974
  })
976
975
  })
977
976
 
978
- // pins the public re-exports from the feature entry point and the published workflows entry
977
+ // pins the public re-exports from the published workflows entry
979
978
  export type _PublicBuildTailOptions = [
980
- featureEntry.BuildTailOptions,
981
- featureEntry.FeedbackTarget,
982
- featureEntry.ReviewRange,
983
979
  bundled.BuildTailOptions,
984
980
  bundled.FeedbackTarget,
985
981
  bundled.ReviewRange,
@@ -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,
@@ -16,20 +17,24 @@ import {
16
17
  } from "../../flows/index.js"
17
18
  import type { ReviewNote } from "../../steering/index.js"
18
19
  import * as kit from "../../workflow-kit/index.js"
19
- import { guarded, healthy, type EscalationCount } from "../shared/health.js"
20
- import { NO_FINDINGS, type LensReport } from "../shared/lenses.js"
20
+ import { guarded, healthy, type EscalationCount } from "./health.js"
21
+ import { NO_FINDINGS, type LensReport } from "./lenses.js"
21
22
  import {
23
+ answerReviewQuestions,
22
24
  awaitReview,
25
+ collecting,
23
26
  fixCheck,
27
+ fixNits,
24
28
  fixRisks,
25
29
  QUALITY,
26
30
  REQUIREMENTS,
27
31
  REVIEW,
28
32
  reviewMissing,
29
- } from "../shared/steps.js"
30
- import { closeReview, isOptionalObject, tail, type TailOptions } from "../shared/tail.js"
31
- import * as t from "../shared/text.js"
32
- import { answerReviewQuestions, collecting, fixNits } from "./steps.js"
33
+ } from "./steps.js"
34
+ import { learnStandards, settlesStandard, standardsAddedSince, standardsPending } from "./learn.js"
35
+ import { parseStandards, STANDARDS } from "./standards.js"
36
+ import { closeReview, isOptionalObject, tail, type TailOptions } from "./tail.js"
37
+ import * as t from "./text.js"
33
38
 
34
39
  export type { ReviewOutcome } from "../../workflow-kit/index.js"
35
40
 
@@ -189,6 +194,10 @@ const composing = (
189
194
  weakened: kit.removedAssertions(changesSince(base)),
190
195
  nitFix,
191
196
  answered,
197
+ standards: {
198
+ added: standardsAddedSince(base),
199
+ undecided: parseStandards(read(STANDARDS) ?? "").undecided,
200
+ },
192
201
  }),
193
202
  [QUALITY],
194
203
  )
@@ -200,10 +209,11 @@ const composing = (
200
209
  * The quality lap's notes reach a human over everything since `base`, who
201
210
  * answers each one — fix as suggested, won't fix, or their own answer — and
202
211
  * may comment. A fix joins the nit batch, a won't-fix is dropped, and an own
203
- * answer or comment is judged: edits go to another planning lap, questions
204
- * are answered in place, nits fixed in one batch, praise dropped.
212
+ * answer or comment is judged: edits end the review as `feedback` for the
213
+ * caller to build, questions are answered in place, nits fixed in one batch,
214
+ * praise dropped.
205
215
  */
206
- export const review = (
216
+ export const humanReview = (
207
217
  base: string,
208
218
  escalations: EscalationCount = { rounds: 0 },
209
219
  options: Omit<BuildTailOptions, "fixFirst" | "green"> = {},
@@ -236,6 +246,9 @@ export const review = (
236
246
  collecting: feedback.collecting,
237
247
  triage: triage(severities),
238
248
  decided: (decisions) => decisions.forEach((d) => answered.add(d.anchor)),
249
+ settles: settlesStandard,
250
+ learn: learnStandards,
251
+ pending: standardsPending,
239
252
  answerQuestions: answerReviewQuestions,
240
253
  fixNits: async (notes) => {
241
254
  await guarded((n: typeof notes) => fixNits(n, fixAccess, rules), guard)(notes)
@@ -316,7 +329,7 @@ export function buildTail(
316
329
  )
317
330
  }
318
331
  const [base, form] = options
319
- return tail((escalations, report) => review(base, escalations, { ...form, report }), {
332
+ return tail((escalations, report) => humanReview(base, escalations, { ...form, report }), {
320
333
  ...form,
321
334
  triage: true,
322
335
  })
@@ -21,6 +21,7 @@ describe("the bundled workflow's skills export", () => {
21
21
  "build.review.fix.nits",
22
22
  "build.review.fix.risks",
23
23
  "notes",
24
+ "feedback",
24
25
  "refactor",
25
26
  "refactor.fix.suite",
26
27
  "refactor.health",
@@ -71,6 +72,7 @@ describe("the bundled workflow's skills export", () => {
71
72
  "build.review.fix.nits": ["incremental-implementation", "code-simplification"],
72
73
  "build.review.fix.risks": ["debugging-and-error-recovery", "incremental-implementation"],
73
74
  notes: ["incremental-implementation", "code-simplification"],
75
+ feedback: ["test-driven-development", "incremental-implementation"],
74
76
  refactor: ["ponytail"],
75
77
  "refactor.fix.suite": ["ponytail", "debugging-and-error-recovery"],
76
78
  "refactor.health": ["ponytail", "debugging-and-error-recovery"],
@@ -22,6 +22,8 @@ export const skills = (
22
22
  design: ["spec-driven-development", "planning-and-task-breakdown"],
23
23
  // `fix`'s turn over the notes a review handed on.
24
24
  notes: ["incremental-implementation", "code-simplification"],
25
+ // `review`'s turn building what a review round collected.
26
+ feedback: ["test-driven-development", "incremental-implementation"],
25
27
  // The refactor's own turns load `ponytail`; `refactor.build.*` carry the `build.*` lists.
26
28
  ...build,
27
29
  ...Object.fromEntries(Object.entries(build).map(([name, list]) => [`refactor.${name}`, list])),
@@ -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
+ }