@pmelab/gtd 42.0.0 → 44.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 +96 -48
- package/claude/hooks/sketch.ts +1 -1
- package/dist/chunks/{reserved-D7MNY9QJ.mjs → reserved-DJk_6-ju.mjs} +75 -46
- package/dist/chunks/review-X7Jfz0dY.mjs +249 -0
- package/dist/chunks/{steering-oCDdQlpP.mjs → steering-D4LUQ-av.mjs} +95 -12
- package/dist/chunks/{tail-D2o2h5cc.mjs → tail-5w5mxGTq.mjs} +3 -3
- package/dist/gtd.bundle.mjs +488 -30
- package/dist/steering.mjs +2 -2
- package/dist/testkit.mjs +447 -27
- package/dist/web/main.js +42 -13
- package/dist/workflow-kit.mjs +61 -25
- package/dist/workflows/cleanup.mjs +2 -2
- package/dist/workflows/feature.mjs +7 -290
- package/dist/workflows/fix.mjs +3 -3
- package/dist/workflows/review.mjs +27 -7
- package/dist/workflows/rollout.mjs +1 -1
- package/dist/workflows.mjs +287 -161
- package/package.json +1 -1
- package/src/cli/Cli.test.ts +33 -1
- package/src/cli/Cli.ts +70 -6
- package/src/cli/History.ts +15 -0
- package/src/cli/Judges.test.ts +373 -0
- package/src/cli/Judges.ts +624 -0
- package/src/cli/Metrics.ts +3 -16
- package/src/cli/Program.test.ts +3 -1
- package/src/cli/Program.ts +9 -0
- package/src/steering/FORMATS.md +4 -4
- package/src/steering/index.ts +12 -1
- package/src/steering/review.test.ts +107 -0
- package/src/steering/review.ts +159 -9
- package/src/testkit/EmittedScriptRecognizer.ts +0 -14
- package/src/testkit/steps.ts +4 -0
- package/src/workflow-kit/review.test.ts +170 -26
- package/src/workflow-kit/review.ts +95 -19
- package/src/workflows/feature/coverage.ts +1 -1
- package/src/workflows/feature/feature.test.ts +8 -0
- package/src/workflows/feature/feature.ts +2 -2
- package/src/workflows/feature/index.ts +1 -1
- package/src/workflows/feature/lens-review.feature +16 -4
- package/src/workflows/feature/refactor.ts +1 -1
- package/src/workflows/feature/review-answers.feature +179 -0
- package/src/workflows/feature/seam.ts +1 -1
- package/src/workflows/feature/steps.test.ts +2 -76
- package/src/workflows/feature/steps.ts +5 -48
- package/src/workflows/fix/fix.feature +20 -21
- package/src/workflows/index.ts +1 -1
- package/src/workflows/review/review-door.feature +149 -0
- package/src/workflows/review/review.test.ts +1 -73
- package/src/workflows/review/review.ts +34 -6
- package/src/workflows/shared/__fixtures__/feature-collecting-prompt.txt +6 -5
- package/src/workflows/shared/access.test.ts +2 -11
- package/src/workflows/shared/access.ts +0 -2
- package/src/workflows/{feature → shared}/review.test.ts +9 -61
- package/src/workflows/{feature → shared}/review.ts +40 -48
- package/src/workflows/shared/skills.test.ts +2 -0
- package/src/workflows/shared/skills.ts +2 -0
- package/src/workflows/shared/steps.test.ts +64 -2
- package/src/workflows/shared/steps.ts +40 -1
- package/src/workflows/shared/tail.ts +2 -2
- package/src/workflows/shared/text.test.ts +40 -89
- package/src/workflows/shared/text.ts +63 -54
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import { answered, changesSince, head, judge, numeric, requireRevert, restoreScript, run, showScript, vars, writeScript } from "../flows.mjs";
|
|
2
|
+
import { removedAssertions, review } from "../workflow-kit.mjs";
|
|
3
|
+
import { A as reviewMissing, E as fixNits, F as buildReviewTriageMessage, O as fixRisks, S as awaitReview, T as fixCheck, d as guarded, dt as reviewEditNotesCapture, f as healthy, ft as reviewEditsCapture, h as QUALITY, ht as reviewNotesCapture, w as collecting, x as answerReviewQuestions, y as REVIEW, z as composedReview } from "./reserved-DJk_6-ju.mjs";
|
|
4
|
+
import { i as isOptionalObject, n as closeReview, o as tail, s as NO_FINDINGS } from "./tail-5w5mxGTq.mjs";
|
|
5
|
+
//#region src/workflows/shared/review.ts
|
|
6
|
+
const VERDICTS = [
|
|
7
|
+
"edit",
|
|
8
|
+
"question",
|
|
9
|
+
"nit",
|
|
10
|
+
"praise"
|
|
11
|
+
];
|
|
12
|
+
/** One four-way choice per note the human added to `.gtd/REVIEW.md`. */
|
|
13
|
+
const verdictQuestion = (note) => ({
|
|
14
|
+
id: note.id,
|
|
15
|
+
primitive: "choice",
|
|
16
|
+
instructions: `Classify the human's note on review item "${note.anchor}" (in .gtd/REVIEW.md). Its evidence holds the anchor, the reviewer's text and the human's text.`,
|
|
17
|
+
criteria: "edit: a request to change behaviour, design or scope — anything needing a plan, or any note you are unsure about. question: asks something and wants an answer, with no change requested. nit: a small, local, unambiguous fix (naming, typo, formatting, comment wording) needing no re-plan. praise: an approving remark with nothing to do."
|
|
18
|
+
});
|
|
19
|
+
const TIER_ORDER = {
|
|
20
|
+
critical: 0,
|
|
21
|
+
should: 1,
|
|
22
|
+
could: 2
|
|
23
|
+
};
|
|
24
|
+
/** Capture order: critical, should, could; within a tier one-way doors, then new concepts. */
|
|
25
|
+
const captureKey = (s) => s === void 0 ? 2 : TIER_ORDER[s.tier] * 3 + s.rank;
|
|
26
|
+
const SEVERITY_ANSWERS = {
|
|
27
|
+
security: ["yes", "no"],
|
|
28
|
+
reversibility: ["one-way", "two-way"],
|
|
29
|
+
blastRadius: [
|
|
30
|
+
"local",
|
|
31
|
+
"module",
|
|
32
|
+
"contract"
|
|
33
|
+
],
|
|
34
|
+
newConcept: ["yes", "no"]
|
|
35
|
+
};
|
|
36
|
+
/** Anything unanswered, unrecognised, cut or below `minP` is `critical`, never `could`. */
|
|
37
|
+
const severityOf = (note, judgment, minP) => {
|
|
38
|
+
const ans = (q) => judgment.answers[`${note.id}.${q}`];
|
|
39
|
+
const a = (q) => ans(q)?.answer;
|
|
40
|
+
const rank = () => a("reversibility") === "one-way" ? 0 : a("newConcept") === "yes" ? 1 : 2;
|
|
41
|
+
if (note.forced) return {
|
|
42
|
+
tier: "critical",
|
|
43
|
+
reason: "critical: forced by `!`",
|
|
44
|
+
rank: 2
|
|
45
|
+
};
|
|
46
|
+
if (judgment.truncated.includes(note.id) || Object.entries(SEVERITY_ANSWERS).some(([q, allowed]) => !allowed.includes(a(q) ?? "") || !((ans(q)?.p ?? NaN) >= minP))) return {
|
|
47
|
+
tier: "critical",
|
|
48
|
+
reason: "critical: judge unsure",
|
|
49
|
+
rank: 2
|
|
50
|
+
};
|
|
51
|
+
const critical = [...a("security") === "yes" ? ["security"] : [], ...a("reversibility") === "one-way" ? ["one-way door"] : []];
|
|
52
|
+
if (critical.length > 0) return {
|
|
53
|
+
tier: "critical",
|
|
54
|
+
reason: `critical: ${critical.join(", ")}`,
|
|
55
|
+
rank: rank()
|
|
56
|
+
};
|
|
57
|
+
const should = [...a("blastRadius") === "contract" ? ["contract"] : [], ...a("newConcept") === "yes" ? ["new concept"] : []];
|
|
58
|
+
if (should.length > 0) return {
|
|
59
|
+
tier: "should",
|
|
60
|
+
reason: `should: ${should.join(", ")}`,
|
|
61
|
+
rank: rank()
|
|
62
|
+
};
|
|
63
|
+
return {
|
|
64
|
+
tier: "could",
|
|
65
|
+
reason: "could",
|
|
66
|
+
rank: 2
|
|
67
|
+
};
|
|
68
|
+
};
|
|
69
|
+
const severityQuestions = (note) => [
|
|
70
|
+
{
|
|
71
|
+
id: `${note.id}.security`,
|
|
72
|
+
primitive: "noul",
|
|
73
|
+
instructions: `Does the change the human asks for in note "${note.id}" (on "${note.anchor}") touch security — authentication, authorisation, secrets, untrusted input or data exposure?`,
|
|
74
|
+
criteria: "yes: it changes or could weaken a security property. no: it does not."
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
id: `${note.id}.reversibility`,
|
|
78
|
+
primitive: "choice",
|
|
79
|
+
instructions: `Once the change in note "${note.id}" ships, can it be undone without breaking anyone who already depends on it?`,
|
|
80
|
+
criteria: "one-way: a published API, file format, persisted data, protocol or other contract that cannot be taken back. two-way: can be reverted freely."
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
id: `${note.id}.blastRadius`,
|
|
84
|
+
primitive: "choice",
|
|
85
|
+
instructions: `How far does the change in note "${note.id}" reach?`,
|
|
86
|
+
criteria: "local: one function or file. module: a whole module or several files. contract: a public interface other code or users depend on."
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
id: `${note.id}.newConcept`,
|
|
90
|
+
primitive: "noul",
|
|
91
|
+
instructions: `Does the change in note "${note.id}" introduce a concept new to this codebase — a new abstraction, entity or term? Judge from the evidence, never a glossary; reusing or extending an existing concept is not new.`,
|
|
92
|
+
criteria: "yes: a genuinely new concept. no: reuses or extends an existing one."
|
|
93
|
+
}
|
|
94
|
+
];
|
|
95
|
+
/**
|
|
96
|
+
* One judge rest: a verdict per unforced note (a forced note is asked
|
|
97
|
+
* nothing) plus four severity answers. Dismissing a note is the risky
|
|
98
|
+
* direction, so only a confident non-`edit` verdict on uncut evidence counts;
|
|
99
|
+
* a cut, unanswered or below-floor note is an `edit`. Severity answers on a
|
|
100
|
+
* non-`edit` note are ignored; on an `edit` they order the collecting turn.
|
|
101
|
+
*/
|
|
102
|
+
const triage = (severities) => async (notes) => {
|
|
103
|
+
const asked = notes.filter((n) => !n.forced);
|
|
104
|
+
const judgment = asked.length === 0 ? {
|
|
105
|
+
answers: {},
|
|
106
|
+
truncated: []
|
|
107
|
+
} : await judge("review.triage", {
|
|
108
|
+
questions: asked.flatMap((n) => [verdictQuestion(n), ...severityQuestions(n)]),
|
|
109
|
+
evidence: Object.fromEntries(asked.map((n) => [n.id, `Anchor: ${n.anchor}\nReviewer's text: ${n.before}\nHuman's text: ${n.text}`])),
|
|
110
|
+
message: buildReviewTriageMessage(),
|
|
111
|
+
label: "Judging each note"
|
|
112
|
+
});
|
|
113
|
+
const { answers, truncated } = judgment;
|
|
114
|
+
const minP = numeric(vars.reviewNoteActionable, Infinity);
|
|
115
|
+
const severityMinP = numeric(vars.reviewSeverityMinP, Infinity);
|
|
116
|
+
severities.clear();
|
|
117
|
+
return new Map(notes.map((n) => {
|
|
118
|
+
const verdict = n.forced ? void 0 : VERDICTS.find((v) => v !== "edit" && answered(answers[n.id], v, minP));
|
|
119
|
+
if (verdict !== void 0 && !truncated.includes(n.id)) return [n.id, verdict];
|
|
120
|
+
severities.set(n.id, severityOf(n, judgment, severityMinP));
|
|
121
|
+
return [n.id, "edit"];
|
|
122
|
+
}));
|
|
123
|
+
};
|
|
124
|
+
const isCode = (path) => path !== ".gtd" && !path.startsWith(".gtd/");
|
|
125
|
+
/**
|
|
126
|
+
* gtd writes `.gtd/REVIEW.md` from the quality lap's `report` — no reviewer
|
|
127
|
+
* turn — and removes what the fixer left in `.gtd/QUALITY.md`. A re-review
|
|
128
|
+
* after fixed nits restores the doc as committed at `carry`, answers kept;
|
|
129
|
+
* a fresh one leaves out the notes the human already answered.
|
|
130
|
+
*/
|
|
131
|
+
const composing = (base, report, carry, nitFix, answered = /* @__PURE__ */ new Set()) => run("review.composing", carry === void 0 ? writeScript(REVIEW, composedReview(base, {
|
|
132
|
+
...report,
|
|
133
|
+
weakened: removedAssertions(changesSince(base)),
|
|
134
|
+
nitFix,
|
|
135
|
+
answered
|
|
136
|
+
}), [QUALITY]) : showScript(carry, REVIEW, REVIEW), {
|
|
137
|
+
label: "Composing the review",
|
|
138
|
+
file: REVIEW,
|
|
139
|
+
base
|
|
140
|
+
});
|
|
141
|
+
/**
|
|
142
|
+
* The quality lap's notes reach a human over everything since `base`, who
|
|
143
|
+
* answers each one — fix as suggested, won't fix, or their own answer — and
|
|
144
|
+
* may comment. A fix joins the nit batch, a won't-fix is dropped, and an own
|
|
145
|
+
* answer or comment is judged: edits end the review as `feedback` for the
|
|
146
|
+
* caller to build, questions are answered in place, nits fixed in one batch,
|
|
147
|
+
* praise dropped.
|
|
148
|
+
*/
|
|
149
|
+
const humanReview = (base, escalations = { rounds: 0 }, options = {}) => {
|
|
150
|
+
const { guard, guardRests, fixAccess, rules } = options;
|
|
151
|
+
const feedback = options.feedback ?? {
|
|
152
|
+
file: ".gtd/REQUIREMENTS.md",
|
|
153
|
+
collecting,
|
|
154
|
+
unmatched: "gtd land: no declared pattern matches the pending changes — write .gtd/REQUIREMENTS.md from the feedback, or change nothing when it asks for nothing"
|
|
155
|
+
};
|
|
156
|
+
const severities = /* @__PURE__ */ new Map();
|
|
157
|
+
let nitFix;
|
|
158
|
+
const answered = /* @__PURE__ */ new Set();
|
|
159
|
+
return review(base, {
|
|
160
|
+
file: REVIEW,
|
|
161
|
+
feedback: feedback.file,
|
|
162
|
+
isCode,
|
|
163
|
+
reviewing: (b, carry) => {
|
|
164
|
+
const fixedAt = nitFix;
|
|
165
|
+
nitFix = void 0;
|
|
166
|
+
return composing(b, options.report ?? NO_FINDINGS, carry, fixedAt, answered);
|
|
167
|
+
},
|
|
168
|
+
fixRisks: guarded((notes) => fixRisks(notes, fixAccess, rules), guard),
|
|
169
|
+
keepGreen: () => healthy(() => fixCheck(rules), {
|
|
170
|
+
escalations,
|
|
171
|
+
guard,
|
|
172
|
+
guardRests,
|
|
173
|
+
rules
|
|
174
|
+
}),
|
|
175
|
+
awaitReview: (b) => awaitReview(b),
|
|
176
|
+
reviewMissing,
|
|
177
|
+
collecting: feedback.collecting,
|
|
178
|
+
triage: triage(severities),
|
|
179
|
+
decided: (decisions) => decisions.forEach((d) => answered.add(d.anchor)),
|
|
180
|
+
answerQuestions: answerReviewQuestions,
|
|
181
|
+
fixNits: async (notes) => {
|
|
182
|
+
await guarded((n) => fixNits(n, fixAccess, rules), guard)(notes);
|
|
183
|
+
nitFix = head();
|
|
184
|
+
},
|
|
185
|
+
close: closeReview,
|
|
186
|
+
captures: {
|
|
187
|
+
edits: reviewEditsCapture,
|
|
188
|
+
notes: reviewNotesCapture,
|
|
189
|
+
editNotes: (round, edits, answeredAt) => reviewEditNotesCapture(round, edits.map((n, i) => ({
|
|
190
|
+
n,
|
|
191
|
+
i,
|
|
192
|
+
s: severities.get(n.id),
|
|
193
|
+
key: captureKey(severities.get(n.id))
|
|
194
|
+
})).sort((x, y) => x.key - y.key || x.i - y.i).map(({ n, s }) => ({
|
|
195
|
+
id: n.id,
|
|
196
|
+
anchor: n.anchor,
|
|
197
|
+
text: n.forced ? n.text.replace(/^!\s*/, "") : n.text,
|
|
198
|
+
...s === void 0 ? {} : { label: s.reason }
|
|
199
|
+
})), answeredAt)
|
|
200
|
+
},
|
|
201
|
+
refusals: {
|
|
202
|
+
deleted: "gtd land: review-doc: .gtd/REVIEW.md was deleted — restore it, or leave a note (or edit code) to request changes.",
|
|
203
|
+
unmatched: feedback.unmatched,
|
|
204
|
+
...feedback.dropped === void 0 ? {} : { dropped: feedback.dropped }
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
};
|
|
208
|
+
function buildTail(first, second, ...rest) {
|
|
209
|
+
const options = callForm(first, second, rest);
|
|
210
|
+
if (options === void 0) return Promise.reject(/* @__PURE__ */ new Error("buildTail takes buildTail(base, options?) or the deprecated buildTail(fixFirst, base, green?, options?); this call fits neither"));
|
|
211
|
+
const [base, form] = options;
|
|
212
|
+
return tail((escalations, report) => humanReview(base, escalations, {
|
|
213
|
+
...form,
|
|
214
|
+
report
|
|
215
|
+
}), {
|
|
216
|
+
...form,
|
|
217
|
+
triage: true
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
const isOptionalBoolean = (value) => value == null || typeof value === "boolean";
|
|
221
|
+
const legacyForm = (fixFirst, base, green, options) => isOptionalBoolean(fixFirst) && isOptionalBoolean(green) && isOptionalObject(options) ? [base, {
|
|
222
|
+
...options,
|
|
223
|
+
fixFirst: fixFirst ?? void 0,
|
|
224
|
+
green: green ?? void 0
|
|
225
|
+
}] : void 0;
|
|
226
|
+
const currentForm = (base, options, third, fourth) => isOptionalObject(options) && third == null && fourth == null ? [base, options ?? {}] : void 0;
|
|
227
|
+
const callForm = (first, second, [third, fourth, ...extra]) => {
|
|
228
|
+
if (extra.some((value) => value != null)) return void 0;
|
|
229
|
+
if (typeof second === "string") return legacyForm(first, second, third, fourth);
|
|
230
|
+
if (typeof first === "string") return currentForm(first, second, third, fourth);
|
|
231
|
+
};
|
|
232
|
+
/**
|
|
233
|
+
* Undo the human's review-round code edits. A path changed since the human
|
|
234
|
+
* left it is not overwritten; requireRevert names it instead.
|
|
235
|
+
*/
|
|
236
|
+
const reUnwind = async (feedback) => {
|
|
237
|
+
const { base, restoreFrom, edited } = feedback;
|
|
238
|
+
await run("re-unwind", restoreScript(base, {
|
|
239
|
+
restore: edited.filter((c) => c.status !== "added").map((c) => c.path),
|
|
240
|
+
remove: edited.filter((c) => c.status === "added").map((c) => c.path)
|
|
241
|
+
}, restoreFrom), {
|
|
242
|
+
label: "Re-unwinding your review edit",
|
|
243
|
+
file: REVIEW,
|
|
244
|
+
base
|
|
245
|
+
});
|
|
246
|
+
requireRevert(edited, base);
|
|
247
|
+
};
|
|
248
|
+
//#endregion
|
|
249
|
+
export { reUnwind as i, humanReview as n, isCode as r, buildTail as t };
|
|
@@ -13349,8 +13349,8 @@ const eolOf = (content) => content.includes("\r\n") ? "\r\n" : "\n";
|
|
|
13349
13349
|
const PREFIX_RE$1 = /^(H|A):\s*/;
|
|
13350
13350
|
const lineOf = (node, which) => toLspPosition(node.position[which]).line;
|
|
13351
13351
|
/** The item's own blocks joined — never the item node's span, which can run past its last line into the next bullet's marker. */
|
|
13352
|
-
const itemText = (content, item) => item.children.map((child) => sourceText(content, child)).join(" ").trim();
|
|
13353
|
-
const prefixOf = (content, item) => PREFIX_RE$1.exec(itemText(content, item));
|
|
13352
|
+
const itemText$1 = (content, item) => item.children.map((child) => sourceText(content, child)).join(" ").trim();
|
|
13353
|
+
const prefixOf = (content, item) => PREFIX_RE$1.exec(itemText$1(content, item));
|
|
13354
13354
|
/** A thread is a definition whose body OPENS with a list whose first item carries a prefix — anything else stays a one-shot footnote. */
|
|
13355
13355
|
const isThreadBody = (content, children) => {
|
|
13356
13356
|
const first = children[0];
|
|
@@ -13370,7 +13370,7 @@ const entryOf = (content, item, previous, report) => {
|
|
|
13370
13370
|
return;
|
|
13371
13371
|
}
|
|
13372
13372
|
const author = match[1] === "H" ? "me" : "agent";
|
|
13373
|
-
const text = itemText(content, item).slice(match[0].length).trim();
|
|
13373
|
+
const text = itemText$1(content, item).slice(match[0].length).trim();
|
|
13374
13374
|
if (text === "") report(`empty "${match[1]}:" entry — write your own words there`, start, end);
|
|
13375
13375
|
if (previous === void 0 && author === "agent") report(`the agent never starts a thread — the first entry must be "H:"`, start, end);
|
|
13376
13376
|
else if (previous === author) report(`two consecutive "${match[1]}:" entries — entries must alternate`, start, end);
|
|
@@ -15086,6 +15086,9 @@ const qaDescriptor = {
|
|
|
15086
15086
|
};
|
|
15087
15087
|
//#endregion
|
|
15088
15088
|
//#region src/steering/review.ts
|
|
15089
|
+
const ANSWER_LEAD = "Tick exactly one:";
|
|
15090
|
+
const ANSWER_FIX = "fix as suggested";
|
|
15091
|
+
const ANSWER_WONT_FIX = "won't fix";
|
|
15089
15092
|
/**
|
|
15090
15093
|
* The canonical sample. Every detail here is load-bearing for the formatter
|
|
15091
15094
|
* round-trip: both footnote bodies exceed 80 characters so a reflow actually
|
|
@@ -15352,16 +15355,42 @@ const parseHunk = (content, lines, title, item) => {
|
|
|
15352
15355
|
errors: [hunkSecondPointerFinding(content, lines, item, token, sourceLine, title, file), hunkLineWithoutRangeFinding(content, item, token, sourceLine, title, file)].filter((e) => e !== void 0)
|
|
15353
15356
|
};
|
|
15354
15357
|
};
|
|
15358
|
+
const itemText = (content, item) => item.children.map((c) => sourceText(content, c)).join(" ").replace(/^\[[ xX]\]\s*/, "").trim();
|
|
15359
|
+
/**
|
|
15360
|
+
* The task list right after the `Tick exactly one:` paragraph, with that
|
|
15361
|
+
* paragraph's index. Read by position, never by label, so an edited label or
|
|
15362
|
+
* a free text opening with `./path` is still an answer, not a pointer. Any
|
|
15363
|
+
* count but three boxes is kept, and reads as unanswered.
|
|
15364
|
+
*/
|
|
15365
|
+
const answerListOf = (content, body) => {
|
|
15366
|
+
const lead = body.findIndex((n, i) => n.type === "paragraph" && sourceText(content, n) === "Tick exactly one:" && body[i + 1]?.type === "list" && body[i + 1].children.every((item) => item.checked != null));
|
|
15367
|
+
if (lead === -1) return void 0;
|
|
15368
|
+
const list = body[lead + 1];
|
|
15369
|
+
const last = list.children.length - 1;
|
|
15370
|
+
return {
|
|
15371
|
+
lead,
|
|
15372
|
+
list,
|
|
15373
|
+
options: list.children.map((item, i) => {
|
|
15374
|
+
const text = itemText(content, item);
|
|
15375
|
+
return {
|
|
15376
|
+
checked: item.checked === true,
|
|
15377
|
+
text: i === last && text.toLowerCase() === "_your answer_".toLowerCase() ? "" : text
|
|
15378
|
+
};
|
|
15379
|
+
})
|
|
15380
|
+
};
|
|
15381
|
+
};
|
|
15355
15382
|
/** Splits one chunk's body nodes into its file pointers (hunk pointers are task items, collected recursively at ANY nesting depth via `taskItems` — a nested hunk is the same kind of hunk as a top-level one) and description prose. The description is the chunk's own leading run and never a node that contains a hunk pointer: it stops at the first node whose `taskItems` include a real pointer (at any depth — a blockquote wrapping a pointer list stops it just as a top-level list would). Every node kind in what's left before that point contributes — a `footnoteDefinition` is the one exception, excluded by `blockNodesOfRun` itself (it's a note, never document content). */
|
|
15356
15383
|
const parseChunkBody = (content, lines, title, body) => {
|
|
15357
|
-
const
|
|
15358
|
-
const
|
|
15384
|
+
const answer = answerListOf(content, body);
|
|
15385
|
+
const isAnswer = (n) => answer !== void 0 && (n === answer.list || n === body[answer.lead]);
|
|
15386
|
+
const firstPointerIndex = body.findIndex((n) => !isAnswer(n) && taskItems(n).some((item) => hasPointerToken(content, item)));
|
|
15387
|
+
const leadingRun = (firstPointerIndex === -1 ? body : body.slice(0, firstPointerIndex)).filter((n) => !isAnswer(n));
|
|
15359
15388
|
const descriptionNodes = blockNodesOfRun(content, leadingRun);
|
|
15360
15389
|
const descriptionInline = collapseImages(blockRunInline(content, leadingRun));
|
|
15361
15390
|
const description = leadingRun.filter((n) => n.type !== "footnoteDefinition").map((n) => sourceText(content, n)).join(" ").trim();
|
|
15362
15391
|
const files = [];
|
|
15363
15392
|
const errors = [];
|
|
15364
|
-
for (const item of body.flatMap((n) => taskItems(n))) {
|
|
15393
|
+
for (const item of body.filter((n) => !isAnswer(n)).flatMap((n) => taskItems(n))) {
|
|
15365
15394
|
const parsed = parseHunk(content, lines, title, item);
|
|
15366
15395
|
if (!parsed) continue;
|
|
15367
15396
|
files.push(parsed.file);
|
|
@@ -15372,7 +15401,9 @@ const parseChunkBody = (content, lines, title, body) => {
|
|
|
15372
15401
|
descriptionNodes,
|
|
15373
15402
|
descriptionInline,
|
|
15374
15403
|
files,
|
|
15375
|
-
errors
|
|
15404
|
+
errors,
|
|
15405
|
+
...answer === void 0 ? {} : { answers: answer.options },
|
|
15406
|
+
boxed: body.some((n) => !isAnswer(n) && taskItems(n).length > 0)
|
|
15376
15407
|
};
|
|
15377
15408
|
};
|
|
15378
15409
|
/** Splits the tree's top-level nodes into consecutive `##` chunk blocks — a `## ` line found only inside a fence never becomes a heading node at all, so it's never mistaken for a chunk. */
|
|
@@ -15408,9 +15439,9 @@ const parseChangesets = (tree, content, lines) => {
|
|
|
15408
15439
|
const errors = [];
|
|
15409
15440
|
for (const { heading, headingLine, body } of splitChunks(tree)) {
|
|
15410
15441
|
const title = headingText$1(content, heading);
|
|
15411
|
-
const { description, descriptionNodes, descriptionInline, files, errors: bodyErrors } = parseChunkBody(content, lines, title, body);
|
|
15442
|
+
const { description, descriptionNodes, descriptionInline, files, answers, boxed, errors: bodyErrors } = parseChunkBody(content, lines, title, body);
|
|
15412
15443
|
errors.push(...bodyErrors);
|
|
15413
|
-
if (files.length === 0 &&
|
|
15444
|
+
if (files.length === 0 && boxed) errors.push({
|
|
15414
15445
|
message: `Chunk "${title}" has no file pointers`,
|
|
15415
15446
|
line: headingLine,
|
|
15416
15447
|
range: nodeRange(heading)
|
|
@@ -15421,7 +15452,8 @@ const parseChangesets = (tree, content, lines) => {
|
|
|
15421
15452
|
descriptionNodes,
|
|
15422
15453
|
descriptionInline,
|
|
15423
15454
|
files,
|
|
15424
|
-
headingLine
|
|
15455
|
+
headingLine,
|
|
15456
|
+
...answers === void 0 ? {} : { answers }
|
|
15425
15457
|
});
|
|
15426
15458
|
}
|
|
15427
15459
|
if (changesets.length === 0) errors.push({ message: "REVIEW.md has no '##' chunks" });
|
|
@@ -15502,7 +15534,9 @@ const isHunkPointerItem = (content, item) => {
|
|
|
15502
15534
|
* clear, so a caller can skip the write and leave the mtime untouched.
|
|
15503
15535
|
*/
|
|
15504
15536
|
const reviewClearTicks = (content) => {
|
|
15505
|
-
const
|
|
15537
|
+
const tree = parseMarkdown(content);
|
|
15538
|
+
const answers = new Set(splitChunks(tree).flatMap(({ body }) => answerListOf(content, body)?.list.children ?? []));
|
|
15539
|
+
const offsets = taskItems(tree).filter((item) => item.checked === true && !answers.has(item) && isHunkPointerItem(content, item)).map((item) => checkboxOffset(content, item)).filter((o) => o !== void 0).sort((a, b) => a - b);
|
|
15506
15540
|
if (offsets.length === 0) return content;
|
|
15507
15541
|
let result = "";
|
|
15508
15542
|
let cursor = 0;
|
|
@@ -15936,6 +15970,55 @@ const reviewRisks = (content) => parseReviewDoc(content).changesets.flatMap((chu
|
|
|
15936
15970
|
id: `risk-${i + 1}`,
|
|
15937
15971
|
...r
|
|
15938
15972
|
}));
|
|
15973
|
+
const CHOICES = [
|
|
15974
|
+
"fix",
|
|
15975
|
+
"wontfix",
|
|
15976
|
+
"answer"
|
|
15977
|
+
];
|
|
15978
|
+
const decisionOf = (chunk) => {
|
|
15979
|
+
if (chunk.answers?.length !== 3) return void 0;
|
|
15980
|
+
const ticked = chunk.answers.flatMap((o, i) => o.checked ? [i] : []);
|
|
15981
|
+
if (ticked.length !== 1) return void 0;
|
|
15982
|
+
const i = ticked[0];
|
|
15983
|
+
const text = i === 2 ? chunk.answers[2].text : "";
|
|
15984
|
+
if (i === 2 && text === "") return void 0;
|
|
15985
|
+
return {
|
|
15986
|
+
anchor: chunk.title,
|
|
15987
|
+
body: chunk.description,
|
|
15988
|
+
choice: CHOICES[i],
|
|
15989
|
+
text
|
|
15990
|
+
};
|
|
15991
|
+
};
|
|
15992
|
+
/**
|
|
15993
|
+
* Notes whose answer boxes do not hold exactly one tick (a ticked free-text
|
|
15994
|
+
* slot filled in), with their 1-based heading line and why; and, given the
|
|
15995
|
+
* file `before` the edit, every note whose boxes were deleted.
|
|
15996
|
+
*/
|
|
15997
|
+
const unansweredNotes = (content, before) => {
|
|
15998
|
+
const now = parseReviewDoc(content).changesets;
|
|
15999
|
+
const boxed = new Set(now.filter((c) => c.answers !== void 0).map((c) => c.title));
|
|
16000
|
+
const removed = (before === void 0 ? [] : parseReviewDoc(before).changesets).filter((c) => c.answers !== void 0 && !boxed.has(c.title)).map((c) => ({
|
|
16001
|
+
title: c.title,
|
|
16002
|
+
line: 0,
|
|
16003
|
+
reason: "its answer boxes were deleted — restore them"
|
|
16004
|
+
}));
|
|
16005
|
+
return [...now.filter((c) => c.answers !== void 0 && decisionOf(c) === void 0).map((c) => ({
|
|
16006
|
+
title: c.title,
|
|
16007
|
+
line: c.headingLine + 1,
|
|
16008
|
+
reason: c.answers.length === 3 ? "tick exactly one box" : `it has ${c.answers.length} boxes — restore the three gtd wrote`
|
|
16009
|
+
})), ...removed];
|
|
16010
|
+
};
|
|
16011
|
+
/** Every answer in `after` that `before` did not already hold for the same note, in document order. */
|
|
16012
|
+
const reviewDecisions = (before, after) => {
|
|
16013
|
+
const prior = new Map(parseReviewDoc(before).changesets.map((c) => [c.title, c]));
|
|
16014
|
+
return parseReviewDoc(after).changesets.flatMap((chunk) => {
|
|
16015
|
+
const decision = decisionOf(chunk);
|
|
16016
|
+
if (decision === void 0) return [];
|
|
16017
|
+
const was = prior.get(chunk.title);
|
|
16018
|
+
const old = was === void 0 ? void 0 : decisionOf(was);
|
|
16019
|
+
return old?.choice === decision.choice && old.text === decision.text ? [] : [decision];
|
|
16020
|
+
});
|
|
16021
|
+
};
|
|
15939
16022
|
//#endregion
|
|
15940
16023
|
//#region src/steering/Edits.ts
|
|
15941
16024
|
/** Splices `edits` into `content`, sorted last-to-first so an earlier range's offset is never invalidated by a later edit. Positions are 0-based line/character over `\n`-split lines. */
|
|
@@ -16304,4 +16387,4 @@ const viewOf = (format, content) => ({
|
|
|
16304
16387
|
/** Every OPEN thread — last entry is the agent's — as `{ name, line, firstMe }` (0-based line). `[]` for a format with no thread concept. */
|
|
16305
16388
|
const openThreads = (format, content) => format.openThreads?.(content) ?? [];
|
|
16306
16389
|
//#endregion
|
|
16307
|
-
export {
|
|
16390
|
+
export { parseFootnotes as C, headingSectionBodies as D, getParseCount as E, headingSections as O, openThreadFindings as S, parseThreadsWithFindings as T, unansweredNotes as _, unansweredQuestions as a, FOOTNOTE_ACTION_TITLE as b, stripCodeThreads as c, ANSWER_FIX as d, ANSWER_LEAD as f, reviewRisks as g, reviewNotes as h, steeringFormatOrFreeForm as i, formatFinding as k, freeFormFormat as l, reviewDecisions as m, openThreads as n, viewOf as o, ANSWER_WONT_FIX as p, steeringFormatFor as r, parseCodeThreads as s, BUILT_IN_MODE_NAMES as t, applySteeringEdits as u, FREE_TEXT_PLACEHOLDER as v, parseThreads as w, THREAD_REPLY_ACTION_TITLE as x, isAnswered as y };
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { answered, head, judge, mark, numeric, read, refuse, removeScript, run, scope, sectionBodies, showScript, vars, writeScript } from "../flows.mjs";
|
|
2
2
|
import { fixFindings, lensEntriesOf, lensOf, qualityLap as qualityLap$1, reviewToHandOff } from "../workflow-kit.mjs";
|
|
3
|
-
import {
|
|
3
|
+
import { A as reviewMissing, D as fixQuality, M as reviewing, O as fixRisks, S as awaitReview, T as fixCheck, Y as handOffAwaitReviewMessage, d as guarded, f as healthy, h as QUALITY, it as qualityTriageMessage, j as reviewQuality, l as escalation, rt as qualityFindingsFile, y as REVIEW } from "./reserved-DJk_6-ju.mjs";
|
|
4
4
|
//#region src/workflows/shared/lenses.ts
|
|
5
5
|
const NO_FINDINGS = {
|
|
6
6
|
notes: [],
|
|
@@ -217,7 +217,7 @@ const runTail = (finish, options) => scope("build", async () => {
|
|
|
217
217
|
return finish(escalations, NO_FINDINGS);
|
|
218
218
|
});
|
|
219
219
|
/**
|
|
220
|
-
* The
|
|
220
|
+
* The fix workflow's human review since `base`: a sign-off closes
|
|
221
221
|
* `.gtd/REVIEW.md`; a note leaves it, with the reviewer's own version as
|
|
222
222
|
* `REVIEW_ORIG`, for the next `fix` to read — never a re-plan.
|
|
223
223
|
*/
|
|
@@ -237,7 +237,7 @@ const handOffReview = (base, escalations, guard) => reviewToHandOff(base, {
|
|
|
237
237
|
unparsed: "gtd land: review-doc: your edit to .gtd/REVIEW.md contains no note fix can read — restore the deleted chunk, or write the note as a line under its entry.",
|
|
238
238
|
deleted: "gtd land: review-doc: .gtd/REVIEW.md was deleted — restore it, or leave a note to hand to fix."
|
|
239
239
|
});
|
|
240
|
-
/** The tail the
|
|
240
|
+
/** The tail the fix workflow runs: ends at the hand-off review since `base`. */
|
|
241
241
|
const handOffTail = (fixFirst, base, guard) => tail((escalations) => handOffReview(base, escalations, guard), {
|
|
242
242
|
fixFirst,
|
|
243
243
|
guard
|