@pmelab/gtd 24.3.0 → 24.4.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 +15 -4
- package/dist/chunks/{reserved-DJwU5C4z.mjs → reserved-B9pG93Fc.mjs} +52 -12
- package/dist/chunks/{steering-D-PvRC5W.mjs → steering-DVILanVf.mjs} +16 -5
- package/dist/flows.mjs +9 -1
- package/dist/gtd.bundle.mjs +779 -812
- package/dist/steering.mjs +2 -2
- package/dist/testkit.mjs +663 -158
- package/dist/workflow-kit.mjs +9 -7
- package/dist/workflows/feature.mjs +152 -35
- package/dist/workflows/fix.mjs +2 -2
- package/dist/workflows/review.mjs +1 -1
- package/dist/workflows.mjs +223 -54
- package/package.json +1 -1
- package/src/cli/Program.test.ts +3 -8
- package/src/cli/Program.ts +1 -8
- package/src/edge/Edge.test.ts +5 -2
- package/src/edge/Edge.ts +3 -4
- package/src/emit/index.ts +1 -0
- package/src/flows/scripts.ts +6 -0
- package/src/platform/Git.test.ts +6 -9
- package/src/steering/FORMATS.md +1 -1
- package/src/steering/SteeringFormat.test.ts +9 -1
- package/src/steering/SteeringFormat.ts +7 -0
- package/src/steering/contract.test.ts +3 -0
- package/src/steering/index.ts +1 -0
- package/src/steering/review.test.ts +30 -0
- package/src/steering/review.ts +8 -2
- package/src/testkit/EmittedScriptRecognizer.test.ts +29 -30
- package/src/testkit/EmittedScriptRecognizer.ts +148 -51
- package/src/testkit/EmittedScripts.test.ts +166 -0
- package/src/testkit/FlowScripts.test.ts +242 -0
- package/src/testkit/FlowScripts.ts +326 -0
- package/src/testkit/GitTiers.ts +140 -49
- package/src/testkit/InMemRepo.ts +35 -2
- package/src/testkit/Layers.ts +10 -2
- package/src/testkit/TestRepo.ts +4 -1
- package/src/testkit/index.ts +1 -1
- package/src/workflow/discovery.test.ts +147 -0
- package/src/workflow/discovery.ts +5 -0
- package/src/workflow/fingerprint.test.ts +20 -4
- package/src/workflow/fingerprint.ts +8 -9
- package/src/workflow/index.ts +1 -6
- package/src/workflow/load.test.ts +5 -19
- package/src/workflow/load.ts +94 -10
- package/src/workflow-kit/review.test.ts +84 -0
- package/src/workflow-kit/review.ts +9 -6
- package/src/workflows/feature/review.test.ts +238 -12
- package/src/workflows/feature/review.ts +172 -31
- package/src/workflows/feature/steps.test.ts +12 -0
- package/src/workflows/feature/steps.ts +8 -1
- package/src/workflows/shared/access.test.ts +4 -2
- package/src/workflows/shared/access.ts +1 -0
- package/src/workflows/shared/options.test.ts +8 -0
- package/src/workflows/shared/options.ts +1 -0
- package/src/workflows/shared/steps.ts +2 -0
- package/src/workflows/shared/tail.ts +3 -11
- package/src/workflows/shared/text.test.ts +106 -0
- package/src/workflows/shared/text.ts +57 -1
- package/src/workflows/shared/vars.ts +1 -0
package/dist/workflows.mjs
CHANGED
|
@@ -217,6 +217,13 @@ const removeScript = (paths) => [
|
|
|
217
217
|
...removal(paths),
|
|
218
218
|
""
|
|
219
219
|
].join("\n");
|
|
220
|
+
/** Write `path` as committed at `ref` to `to`. */
|
|
221
|
+
const showScript = (ref, path, to) => [
|
|
222
|
+
"#!/usr/bin/env sh",
|
|
223
|
+
"set -e",
|
|
224
|
+
`git show ${quote(`${ref}:${path}`)} > ${quote(to)}`,
|
|
225
|
+
""
|
|
226
|
+
].join("\n");
|
|
220
227
|
//#endregion
|
|
221
228
|
//#region src/flows/helpers.ts
|
|
222
229
|
/** Whether a judge answered `expected` at a probability of at least `minP`. */
|
|
@@ -16360,10 +16367,13 @@ const reviewNotes = (before, after) => {
|
|
|
16360
16367
|
return [...parseReviewDoc(after).changesets.flatMap((chunk) => {
|
|
16361
16368
|
const prior = oldChunks.get(chunk.title);
|
|
16362
16369
|
return [...chunkNote(chunk, prior), ...pointerNotes(chunk, prior)];
|
|
16363
|
-
}), ...footnoteNotes(before, after)].sort((a, b) => a.line - b.line).map((f, i) =>
|
|
16364
|
-
|
|
16365
|
-
|
|
16366
|
-
|
|
16370
|
+
}), ...footnoteNotes(before, after)].sort((a, b) => a.line - b.line).map((f, i) => {
|
|
16371
|
+
return {
|
|
16372
|
+
id: `note-${i + 1}`,
|
|
16373
|
+
...f.note,
|
|
16374
|
+
forced: /^!(\s|$)/.test(f.note.text)
|
|
16375
|
+
};
|
|
16376
|
+
});
|
|
16367
16377
|
};
|
|
16368
16378
|
/** Pointer notes the reviewer opened with `Risk:`, in document order — the ones the workflow fixes before the human gate. */
|
|
16369
16379
|
const reviewRisks = (content) => parseReviewDoc(content).changesets.flatMap((chunk) => chunk.files.flatMap((file) => file.note?.startsWith("Risk:") ? [{
|
|
@@ -16526,16 +16536,17 @@ const converse = async (p, base, collectedBefore) => {
|
|
|
16526
16536
|
if (!hasThreadFor("human", p.file)) return collectedAt;
|
|
16527
16537
|
}
|
|
16528
16538
|
};
|
|
16529
|
-
/** Judge every note, then run each verdict's route: answers, then nit fixes, then the edits' lap. */
|
|
16539
|
+
/** Judge every note, then run each verdict's route: answers, then nit fixes, then deferrals, then the edits' lap. */
|
|
16530
16540
|
const routeNotes = async (p, notes, r) => {
|
|
16531
16541
|
const verdicts = await p.triage(notes);
|
|
16532
16542
|
const of = (v) => notes.filter((n) => (verdicts.get(n.id) ?? "edit") === v);
|
|
16533
|
-
const [edits, questions, nits] = [
|
|
16543
|
+
const [edits, questions, nits, deferred] = [
|
|
16534
16544
|
of("edit"),
|
|
16535
16545
|
of("question"),
|
|
16536
|
-
of("nit")
|
|
16546
|
+
of("nit"),
|
|
16547
|
+
of("defer")
|
|
16537
16548
|
];
|
|
16538
|
-
if (edits.length + questions.length + nits.length === 0) return r.unfolded();
|
|
16549
|
+
if (edits.length + questions.length + nits.length + deferred.length === 0) return r.unfolded();
|
|
16539
16550
|
let answeredAt;
|
|
16540
16551
|
if (questions.length > 0) {
|
|
16541
16552
|
await p.answerQuestions(questions);
|
|
@@ -16545,6 +16556,7 @@ const routeNotes = async (p, notes, r) => {
|
|
|
16545
16556
|
await p.fixNits(nits);
|
|
16546
16557
|
await p.keepGreen();
|
|
16547
16558
|
}
|
|
16559
|
+
if (deferred.length > 0) await p.defer(deferred);
|
|
16548
16560
|
if (edits.length > 0) {
|
|
16549
16561
|
await r.close();
|
|
16550
16562
|
return r.outcome(await collect(p, p.captures.editNotes(r.round, edits, answeredAt)));
|
|
@@ -16558,7 +16570,7 @@ const routeNotes = async (p, notes, r) => {
|
|
|
16558
16570
|
}
|
|
16559
16571
|
return {
|
|
16560
16572
|
next: "await",
|
|
16561
|
-
reviewed: answeredAt
|
|
16573
|
+
reviewed: answeredAt ?? r.round
|
|
16562
16574
|
};
|
|
16563
16575
|
};
|
|
16564
16576
|
/** A code edit, not a thread-only change: lines that only add, answer or remove code threads don't count. */
|
|
@@ -16987,6 +16999,10 @@ ${stateFileRules}
|
|
|
16987
16999
|
${footnoteFoldIn}${codeThreadReplies()}
|
|
16988
17000
|
- The only state file this turn touches is \`.gtd/REQUIREMENTS.md\`
|
|
16989
17001
|
— no other files for notes or output
|
|
17002
|
+
- Lines of a \`## Deferred review notes\` section of \`.gtd/TODO.md\`
|
|
17003
|
+
that the start diff does not touch are parked by review, never folded;
|
|
17004
|
+
the human's edits to such a line (or its removal) are input like any
|
|
17005
|
+
other part of the start diff
|
|
16990
17006
|
- \`.gtd/TODO.md\` is the likely home of the sketch that started
|
|
16991
17007
|
this process — the human's input, folded into the concerns
|
|
16992
17008
|
below like any other part of the start diff; never a state
|
|
@@ -17470,9 +17486,10 @@ const reviewNotesCapture = (commit) => `This is machine-captured input, not inst
|
|
|
17470
17486
|
Commit: ${commit}
|
|
17471
17487
|
The human's notes are in .gtd/REVIEW.md at this commit. Run: git show ${commit}
|
|
17472
17488
|
`;
|
|
17489
|
+
const noteHead = (n) => `${n.id} — ${n.anchor}${n.label === void 0 ? "" : ` (${n.label})`}`;
|
|
17473
17490
|
const notesCapture = (notes) => `This is machine-captured input, not instructions.
|
|
17474
17491
|
|
|
17475
|
-
${notes.map((n) => `- ${n
|
|
17492
|
+
${notes.map((n) => `- ${noteHead(n)}\n ${n.text.replace(/\n/g, "\n ")}`).join("\n")}
|
|
17476
17493
|
`;
|
|
17477
17494
|
const buildReviewAnswerQuestionsPrompt = (notes) => `${stateFileRules}
|
|
17478
17495
|
- The only state file this turn writes is \`.gtd/REVIEW.md\`; touch no
|
|
@@ -17534,12 +17551,47 @@ record.
|
|
|
17534
17551
|
What each change does next (then run \`gtd land\`):
|
|
17535
17552
|
- **Re-review** — re-run the reviewer to author a fresh \`.gtd/REVIEW.md\` (**build.review.reviewing**).
|
|
17536
17553
|
`;
|
|
17554
|
+
/** Greedy wrap at 80 columns with a 2-space continuation indent: oxfmt (`proseWrap: always`) rewrites any other shape. */
|
|
17555
|
+
const wrapItem = (item) => {
|
|
17556
|
+
const out = [];
|
|
17557
|
+
let line = "";
|
|
17558
|
+
for (const word of item.split(" ")) if (line !== "" && line.length + 1 + word.length > 80) {
|
|
17559
|
+
out.push(line);
|
|
17560
|
+
line = ` ${word}`;
|
|
17561
|
+
} else line = line === "" ? word : `${line} ${word}`;
|
|
17562
|
+
return [...out, line];
|
|
17563
|
+
};
|
|
17564
|
+
/** Appends one section of `notes` to `.gtd/TODO.md`, wrapped the way oxfmt writes it; an existing file is only ever appended to. */
|
|
17565
|
+
const deferNotesScript = (notes, round) => {
|
|
17566
|
+
return `mkdir -p .gtd
|
|
17567
|
+
if [ -s .gtd/TODO.md ]; then
|
|
17568
|
+
[ -z "$(tail -c1 .gtd/TODO.md)" ] || printf '\\n' >> .gtd/TODO.md
|
|
17569
|
+
printf '\\n' >> .gtd/TODO.md
|
|
17570
|
+
fi
|
|
17571
|
+
printf '%s\\n' ${[
|
|
17572
|
+
`## Deferred review notes (${round.slice(0, 7)})`,
|
|
17573
|
+
"",
|
|
17574
|
+
...notes.flatMap((n) => wrapItem(`- ${noteHead(n)}: ${n.text.replace(/\s+/g, " ")}`))
|
|
17575
|
+
].map(quote).join(" ")} >> .gtd/TODO.md
|
|
17576
|
+
`;
|
|
17577
|
+
};
|
|
17578
|
+
/** The gate right after a deferral: landing clean signs off without these notes. */
|
|
17579
|
+
const buildReviewAwaitAfterDeferMessage = (base, deferred) => `${buildReviewAwaitReviewMessage(base)}
|
|
17580
|
+
Deferred to \`.gtd/TODO.md\` just now, NOT acted on in this process — landing
|
|
17581
|
+
clean signs off without them:
|
|
17582
|
+
|
|
17583
|
+
${deferred.map((n) => `- ${noteHead(n)}: ${n.text.replace(/\s*\n\s*/g, " ")}`).join("\n")}
|
|
17584
|
+
`;
|
|
17537
17585
|
const buildReviewTriageMessage = () => `Judging each note the human added to \`.gtd/REVIEW.md\` (a line note, a
|
|
17538
17586
|
footnote, or prose under a chunk) with one verdict: \`edit\` (a change
|
|
17539
17587
|
request), \`question\`, \`nit\` (a small fix needing no re-plan) or
|
|
17540
17588
|
\`praise\`. Run \`gtd judge answer\` and pipe a choice per note — or land
|
|
17541
17589
|
untouched, which treats every note as \`edit\` (the conservative
|
|
17542
17590
|
default; a skipped judgment never dismisses a note).
|
|
17591
|
+
|
|
17592
|
+
Also judge each note's severity, one id per note: \`note-<n>.security\`,
|
|
17593
|
+
\`note-<n>.reversibility\`, \`note-<n>.blastRadius\` and
|
|
17594
|
+
\`note-<n>.newConcept\`.
|
|
17543
17595
|
`;
|
|
17544
17596
|
const buildReviewCollectingPrompt = (capture) => `${styleBlock}
|
|
17545
17597
|
|
|
@@ -17560,6 +17612,8 @@ ${footnoteFoldIn}${codeThreadReplies()}
|
|
|
17560
17612
|
- When the capture names an answering commit, carry each answered
|
|
17561
17613
|
question into \`.gtd/REQUIREMENTS.md\`'s \`## Answered Questions\` as
|
|
17562
17614
|
\`### <the note>\` plus its answer — the lap replaces \`.gtd/REVIEW.md\`
|
|
17615
|
+
- Keep capture order, and carry each note's \`(tier: reason)\`
|
|
17616
|
+
label into the concern folded from it
|
|
17563
17617
|
- Nit notes were already fixed: never re-raise a fixed nit as a concern
|
|
17564
17618
|
- Finish by running \`gtd check review .gtd/REVIEW.md\` and fix
|
|
17565
17619
|
what it reports
|
|
@@ -17786,12 +17840,13 @@ const fixRisks = (notes) => agentWithSkills("review.fix.risks.fixing", buildRevi
|
|
|
17786
17840
|
system: reviewerSystem(),
|
|
17787
17841
|
allowEmpty: true
|
|
17788
17842
|
});
|
|
17789
|
-
const awaitReview = (base, message = buildReviewAwaitReviewMessage(base)) => human("review.await-review", {
|
|
17843
|
+
const awaitReview = (base, message = buildReviewAwaitReviewMessage(base), acceptClean = false) => human("review.await-review", {
|
|
17790
17844
|
message,
|
|
17791
17845
|
label: "Awaiting your review",
|
|
17792
17846
|
file: REVIEW,
|
|
17793
17847
|
mode: "review",
|
|
17794
|
-
base
|
|
17848
|
+
base,
|
|
17849
|
+
...acceptClean ? { acceptClean } : {}
|
|
17795
17850
|
});
|
|
17796
17851
|
/** The round at commit `round` left no `.gtd/REVIEW.md`. */
|
|
17797
17852
|
const reviewMissing = (round) => human("review.review-missing", {
|
|
@@ -37207,6 +37262,7 @@ const defaults = {
|
|
|
37207
37262
|
judgeBudgetBytes: "32768",
|
|
37208
37263
|
judgeIdenticalMinP: "0.7",
|
|
37209
37264
|
reviewNoteActionable: "0.7",
|
|
37265
|
+
reviewSeverityMinP: "0.7",
|
|
37210
37266
|
qualityReviews: "correctness, owasp-security, ponytail-review, test-audit, conventions, spec-challenge"
|
|
37211
37267
|
};
|
|
37212
37268
|
/** Environment settings. */
|
|
@@ -37224,6 +37280,7 @@ const ShippedOptions = Struct({
|
|
|
37224
37280
|
judgeBudgetBytes: setting,
|
|
37225
37281
|
judgeIdenticalMinP: setting,
|
|
37226
37282
|
reviewNoteActionable: setting,
|
|
37283
|
+
reviewSeverityMinP: setting,
|
|
37227
37284
|
qualityReviews: setting,
|
|
37228
37285
|
testCommand: setting,
|
|
37229
37286
|
fastTestCommand: setting,
|
|
@@ -37310,12 +37367,6 @@ const tail = (fixFirst, finish, built) => scope("build", async () => {
|
|
|
37310
37367
|
else await repair();
|
|
37311
37368
|
return finish(escalations, frozen);
|
|
37312
37369
|
});
|
|
37313
|
-
const handOffScript = (reviewed) => [
|
|
37314
|
-
"#!/usr/bin/env sh",
|
|
37315
|
-
"set -e",
|
|
37316
|
-
`git show ${quote(`${reviewed}:${REVIEW}`)} > ${quote(REVIEW_ORIG)}`,
|
|
37317
|
-
""
|
|
37318
|
-
].join("\n");
|
|
37319
37370
|
/**
|
|
37320
37371
|
* The review and fix workflows' human review since `base`: a sign-off closes
|
|
37321
37372
|
* `.gtd/REVIEW.md`; a note leaves it, with the reviewer's own version as
|
|
@@ -37329,7 +37380,7 @@ const handOffReview = (base, escalations) => reviewToHandOff(base, {
|
|
|
37329
37380
|
awaitReview: (b) => awaitReview(b, handOffAwaitReviewMessage(b)),
|
|
37330
37381
|
reviewMissing,
|
|
37331
37382
|
close: closeReview,
|
|
37332
|
-
handOff: (reviewed) => run("review.handing-off",
|
|
37383
|
+
handOff: (reviewed) => run("review.handing-off", showScript(reviewed, REVIEW, REVIEW_ORIG), { label: "Handing your notes to fix" }),
|
|
37333
37384
|
threadsPending: "gtd land: open-threads: a thread in .gtd/REVIEW.md or an `H:` code comment waits on the agent — nothing answers it here; delete it, or turn it into a note for fix.",
|
|
37334
37385
|
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.",
|
|
37335
37386
|
deleted: "gtd land: review-doc: .gtd/REVIEW.md was deleted — restore it, or leave a note to hand to fix."
|
|
@@ -37407,6 +37458,11 @@ const collecting = (capture) => agentWithSkills("review.collecting", buildReview
|
|
|
37407
37458
|
system: reviewerSystem(),
|
|
37408
37459
|
allowEmpty: true
|
|
37409
37460
|
});
|
|
37461
|
+
/** `build.review.defer`: append `notes` to `.gtd/TODO.md` as one section; the step's landing is the commit. */
|
|
37462
|
+
const deferNotes = (notes, round) => run("review.defer", deferNotesScript(notes, round), {
|
|
37463
|
+
label: "Deferring could-notes to TODO.md",
|
|
37464
|
+
file: ".gtd/TODO.md"
|
|
37465
|
+
});
|
|
37410
37466
|
//#endregion
|
|
37411
37467
|
//#region src/workflows/feature/packages.ts
|
|
37412
37468
|
/** Package 0: the e2e scenarios, written red. */
|
|
@@ -37530,23 +37586,106 @@ const verdictQuestion = (note) => ({
|
|
|
37530
37586
|
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.`,
|
|
37531
37587
|
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."
|
|
37532
37588
|
});
|
|
37589
|
+
const SEVERITY_ANSWERS = {
|
|
37590
|
+
security: ["yes", "no"],
|
|
37591
|
+
reversibility: ["one-way", "two-way"],
|
|
37592
|
+
blastRadius: [
|
|
37593
|
+
"local",
|
|
37594
|
+
"module",
|
|
37595
|
+
"contract"
|
|
37596
|
+
],
|
|
37597
|
+
newConcept: ["yes", "no"]
|
|
37598
|
+
};
|
|
37533
37599
|
/**
|
|
37534
|
-
*
|
|
37535
|
-
*
|
|
37536
|
-
* a cut, unanswered or below-floor note is an `edit`, and a blank floor makes
|
|
37537
|
-
* every note one.
|
|
37600
|
+
* Deferring is the risky direction: anything unanswered, unrecognised, cut or
|
|
37601
|
+
* below `minP` is `critical`, never `could`.
|
|
37538
37602
|
*/
|
|
37539
|
-
const
|
|
37540
|
-
const
|
|
37541
|
-
|
|
37542
|
-
|
|
37603
|
+
const severityOf = (note, judgment, minP) => {
|
|
37604
|
+
const ans = (q) => judgment.answers[`${note.id}.${q}`];
|
|
37605
|
+
const a = (q) => ans(q)?.answer;
|
|
37606
|
+
const rank = () => a("reversibility") === "one-way" ? 0 : a("newConcept") === "yes" ? 1 : 2;
|
|
37607
|
+
if (note.forced) return {
|
|
37608
|
+
tier: "critical",
|
|
37609
|
+
reason: "critical: forced by `!`",
|
|
37610
|
+
rank: 2
|
|
37611
|
+
};
|
|
37612
|
+
if (judgment.truncated.includes(note.id) || Object.entries(SEVERITY_ANSWERS).some(([q, allowed]) => !allowed.includes(a(q) ?? "") || !((ans(q)?.p ?? NaN) >= minP))) return {
|
|
37613
|
+
tier: "critical",
|
|
37614
|
+
reason: "critical: judge unsure",
|
|
37615
|
+
rank: 2
|
|
37616
|
+
};
|
|
37617
|
+
const critical = [...a("security") === "yes" ? ["security"] : [], ...a("reversibility") === "one-way" ? ["one-way door"] : []];
|
|
37618
|
+
if (critical.length > 0) return {
|
|
37619
|
+
tier: "critical",
|
|
37620
|
+
reason: `critical: ${critical.join(", ")}`,
|
|
37621
|
+
rank: rank()
|
|
37622
|
+
};
|
|
37623
|
+
const should = [...a("blastRadius") === "contract" ? ["contract"] : [], ...a("newConcept") === "yes" ? ["new concept"] : []];
|
|
37624
|
+
if (should.length > 0) return {
|
|
37625
|
+
tier: "should",
|
|
37626
|
+
reason: `should: ${should.join(", ")}`,
|
|
37627
|
+
rank: rank()
|
|
37628
|
+
};
|
|
37629
|
+
return {
|
|
37630
|
+
tier: "could",
|
|
37631
|
+
reason: "could",
|
|
37632
|
+
rank: 2
|
|
37633
|
+
};
|
|
37634
|
+
};
|
|
37635
|
+
const severityQuestions = (note) => [
|
|
37636
|
+
{
|
|
37637
|
+
id: `${note.id}.security`,
|
|
37638
|
+
primitive: "noul",
|
|
37639
|
+
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?`,
|
|
37640
|
+
criteria: "yes: it changes or could weaken a security property. no: it does not."
|
|
37641
|
+
},
|
|
37642
|
+
{
|
|
37643
|
+
id: `${note.id}.reversibility`,
|
|
37644
|
+
primitive: "choice",
|
|
37645
|
+
instructions: `Once the change in note "${note.id}" ships, can it be undone without breaking anyone who already depends on it?`,
|
|
37646
|
+
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."
|
|
37647
|
+
},
|
|
37648
|
+
{
|
|
37649
|
+
id: `${note.id}.blastRadius`,
|
|
37650
|
+
primitive: "choice",
|
|
37651
|
+
instructions: `How far does the change in note "${note.id}" reach?`,
|
|
37652
|
+
criteria: "local: one function or file. module: a whole module or several files. contract: a public interface other code or users depend on."
|
|
37653
|
+
},
|
|
37654
|
+
{
|
|
37655
|
+
id: `${note.id}.newConcept`,
|
|
37656
|
+
primitive: "noul",
|
|
37657
|
+
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.`,
|
|
37658
|
+
criteria: "yes: a genuinely new concept. no: reuses or extends an existing one."
|
|
37659
|
+
}
|
|
37660
|
+
];
|
|
37661
|
+
/**
|
|
37662
|
+
* One judge rest: a verdict per unforced note (a forced note is asked
|
|
37663
|
+
* nothing) plus four severity answers. Dismissing a note is the risky
|
|
37664
|
+
* direction, so only a confident non-`edit` verdict on uncut evidence counts;
|
|
37665
|
+
* a cut, unanswered or below-floor note is an `edit`. Severity answers on a
|
|
37666
|
+
* non-`edit` note are ignored. A confident `could` edit becomes `defer`.
|
|
37667
|
+
*/
|
|
37668
|
+
const triage$1 = (severities) => async (notes) => {
|
|
37669
|
+
const asked = notes.filter((n) => !n.forced);
|
|
37670
|
+
const judgment = asked.length === 0 ? {
|
|
37671
|
+
answers: {},
|
|
37672
|
+
truncated: []
|
|
37673
|
+
} : await judge("review.triage", {
|
|
37674
|
+
questions: asked.flatMap((n) => [verdictQuestion(n), ...severityQuestions(n)]),
|
|
37675
|
+
evidence: Object.fromEntries(asked.map((n) => [n.id, `Anchor: ${n.anchor}\nReviewer's text: ${n.before}\nHuman's text: ${n.text}`])),
|
|
37543
37676
|
message: buildReviewTriageMessage(),
|
|
37544
37677
|
label: "Judging each note"
|
|
37545
37678
|
});
|
|
37679
|
+
const { answers, truncated } = judgment;
|
|
37546
37680
|
const minP = numeric(vars.reviewNoteActionable, Infinity);
|
|
37681
|
+
const severityMinP = numeric(vars.reviewSeverityMinP, Infinity);
|
|
37682
|
+
severities.clear();
|
|
37547
37683
|
return new Map(notes.map((n) => {
|
|
37548
|
-
const verdict = VERDICTS.find((v) => v !== "edit" && answered(answers[n.id], v, minP));
|
|
37549
|
-
|
|
37684
|
+
const verdict = n.forced ? void 0 : VERDICTS.find((v) => v !== "edit" && answered(answers[n.id], v, minP));
|
|
37685
|
+
if (verdict !== void 0 && !truncated.includes(n.id)) return [n.id, verdict];
|
|
37686
|
+
const severity = severityOf(n, judgment, severityMinP);
|
|
37687
|
+
severities.set(n.id, severity);
|
|
37688
|
+
return [n.id, severity.tier === "could" ? "defer" : "edit"];
|
|
37550
37689
|
}));
|
|
37551
37690
|
};
|
|
37552
37691
|
const isCode = (path) => path !== ".gtd" && !path.startsWith(".gtd/");
|
|
@@ -37556,30 +37695,59 @@ const isCode = (path) => path !== ".gtd" && !path.startsWith(".gtd/");
|
|
|
37556
37695
|
* another planning lap, questions are answered in place, nits fixed in one
|
|
37557
37696
|
* batch, praise dropped.
|
|
37558
37697
|
*/
|
|
37559
|
-
const review$1 = (base, escalations = { rounds: 0 }, frozen) =>
|
|
37560
|
-
|
|
37561
|
-
|
|
37562
|
-
|
|
37563
|
-
|
|
37564
|
-
|
|
37565
|
-
|
|
37566
|
-
|
|
37567
|
-
|
|
37568
|
-
|
|
37569
|
-
|
|
37570
|
-
|
|
37571
|
-
|
|
37572
|
-
|
|
37573
|
-
|
|
37574
|
-
|
|
37575
|
-
|
|
37576
|
-
|
|
37577
|
-
|
|
37578
|
-
|
|
37579
|
-
|
|
37580
|
-
|
|
37581
|
-
|
|
37582
|
-
|
|
37698
|
+
const review$1 = (base, escalations = { rounds: 0 }, frozen) => {
|
|
37699
|
+
const severities = /* @__PURE__ */ new Map();
|
|
37700
|
+
let deferred = [];
|
|
37701
|
+
return review$2(base, {
|
|
37702
|
+
file: REVIEW,
|
|
37703
|
+
feedback: REQUIREMENTS,
|
|
37704
|
+
isCode,
|
|
37705
|
+
reviewing: (b, carry) => {
|
|
37706
|
+
deferred = [];
|
|
37707
|
+
return reviewing(b, carry);
|
|
37708
|
+
},
|
|
37709
|
+
fixRisks: (risks) => guarded(frozen, () => fixRisks(risks), "review")(),
|
|
37710
|
+
keepGreen: () => healthy(guarded(frozen, fixCheck, "review"), { escalations }),
|
|
37711
|
+
awaitReview: (b) => {
|
|
37712
|
+
const notes = deferred;
|
|
37713
|
+
deferred = [];
|
|
37714
|
+
return notes.length > 0 ? awaitReview(b, buildReviewAwaitAfterDeferMessage(b, notes), true) : awaitReview(b);
|
|
37715
|
+
},
|
|
37716
|
+
reviewMissing,
|
|
37717
|
+
collecting,
|
|
37718
|
+
triage: triage$1(severities),
|
|
37719
|
+
defer: (notes) => {
|
|
37720
|
+
deferred = notes.map((n) => ({
|
|
37721
|
+
id: n.id,
|
|
37722
|
+
anchor: n.anchor,
|
|
37723
|
+
text: n.text,
|
|
37724
|
+
label: severities.get(n.id).reason
|
|
37725
|
+
}));
|
|
37726
|
+
return deferNotes(deferred, head$1());
|
|
37727
|
+
},
|
|
37728
|
+
answerQuestions: answerReviewQuestions,
|
|
37729
|
+
fixNits: (notes) => guarded(frozen, () => fixNits(notes), "review")(),
|
|
37730
|
+
close: closeReview,
|
|
37731
|
+
captures: {
|
|
37732
|
+
edits: reviewEditsCapture,
|
|
37733
|
+
notes: reviewNotesCapture,
|
|
37734
|
+
editNotes: (round, edits, answeredAt) => reviewEditNotesCapture(round, edits.map((n, i) => ({
|
|
37735
|
+
n,
|
|
37736
|
+
i,
|
|
37737
|
+
s: severities.get(n.id)
|
|
37738
|
+
})).sort((x, y) => Number(x.s?.tier === "should") - Number(y.s?.tier === "should") || (x.s?.rank ?? 2) - (y.s?.rank ?? 2) || x.i - y.i).map(({ n, s }) => ({
|
|
37739
|
+
id: n.id,
|
|
37740
|
+
anchor: n.anchor,
|
|
37741
|
+
text: n.forced ? n.text.replace(/^!\s*/, "") : n.text,
|
|
37742
|
+
...s === void 0 ? {} : { label: s.reason }
|
|
37743
|
+
})), answeredAt)
|
|
37744
|
+
},
|
|
37745
|
+
refusals: {
|
|
37746
|
+
deleted: "gtd land: review-doc: .gtd/REVIEW.md was deleted — restore it, or leave a note (or edit code) to request changes.",
|
|
37747
|
+
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"
|
|
37748
|
+
}
|
|
37749
|
+
});
|
|
37750
|
+
};
|
|
37583
37751
|
/** The build tail: fix (when entered red), keep green, the quality lap, then human review since `base`. */
|
|
37584
37752
|
const buildTail = (fixFirst, base, built) => tail(fixFirst, (escalations, frozen) => review$1(base, escalations, frozen), built);
|
|
37585
37753
|
//#endregion
|
|
@@ -37673,6 +37841,7 @@ const access = (vars) => ({
|
|
|
37673
37841
|
architecture: { write: [".gtd/REQUIREMENTS.md"] },
|
|
37674
37842
|
"architecture.decompose": { write: [".gtd/packages/**", ".gtd/ARCHITECTURE.md"] },
|
|
37675
37843
|
"build.review": { write: [".gtd/REVIEW.md"] },
|
|
37844
|
+
"build.review.defer": { write: [".gtd/TODO.md"] },
|
|
37676
37845
|
"build.review.fix.nits": {},
|
|
37677
37846
|
"build.review.fix.risks": {},
|
|
37678
37847
|
...lensScopes("build.quality", lensesOf(vars.qualityReviews ?? ""), () => ({ write: [] }))
|
|
@@ -37749,4 +37918,4 @@ var workflows_default = feature;
|
|
|
37749
37918
|
*/
|
|
37750
37919
|
const doors = {};
|
|
37751
37920
|
//#endregion
|
|
37752
|
-
export { ARCHITECTURE, ESCALATION, FEEDBACK, FIX_CAP, QUALITY, REQUIREMENTS, REVIEW, REVIEW_ORIG, SCENARIO_PACKAGE, SETUP, ShippedOptions, access, afterTail, agentWithSkills, answerProductQuestions, answerReviewQuestions, answerTechnicalQuestions, architecture, author, awaitReview, base, baseline, build, buildTail, builtInLenses, closeReview, coder, collecting, declaredTests, decompose, workflows_default as default, defaults, describeEscalation, design, doors, driftedScenarios, envDefaults, escalate, escalation, escalationExhausted, fastSuiteGuard, feature, fix, fixCheck, fixFlow, fixNits, fixQuality, fixQualityFindings, fixReviewNotes, fixRisks, fixSuite, freezeScenarios, gate, guarded, handOffReview, handOffTail, healthy, holdWording, lensesOf, nextPackage, ordinaryStart, packageItem, packages, planAndBuild, planner, qualityLap, reUnwind, requireDeclaredTests, retryQuestion, review, reviewFlow, reviewMissing, reviewQuality, reviewing, scenarioText, skills, steering, summary, tail, threads, triage, unwind, wordingDrift };
|
|
37921
|
+
export { ARCHITECTURE, ESCALATION, FEEDBACK, FIX_CAP, QUALITY, REQUIREMENTS, REVIEW, REVIEW_ORIG, SCENARIO_PACKAGE, SETUP, ShippedOptions, access, afterTail, agentWithSkills, answerProductQuestions, answerReviewQuestions, answerTechnicalQuestions, architecture, author, awaitReview, base, baseline, build, buildTail, builtInLenses, closeReview, coder, collecting, declaredTests, decompose, workflows_default as default, defaults, deferNotes, describeEscalation, design, doors, driftedScenarios, envDefaults, escalate, escalation, escalationExhausted, fastSuiteGuard, feature, fix, fixCheck, fixFlow, fixNits, fixQuality, fixQualityFindings, fixReviewNotes, fixRisks, fixSuite, freezeScenarios, gate, guarded, handOffReview, handOffTail, healthy, holdWording, lensesOf, nextPackage, ordinaryStart, packageItem, packages, planAndBuild, planner, qualityLap, reUnwind, requireDeclaredTests, retryQuestion, review, reviewFlow, reviewMissing, reviewQuality, reviewing, scenarioText, skills, steering, summary, tail, threads, triage, unwind, wordingDrift };
|
package/package.json
CHANGED
package/src/cli/Program.test.ts
CHANGED
|
@@ -17,7 +17,7 @@ vi.mock("node:os", async (importOriginal) => ({
|
|
|
17
17
|
|
|
18
18
|
import { runCli, type Command } from "./index.js"
|
|
19
19
|
import { EXIT_USAGE_ERROR, stallDiagnosis, noopText, WIRE_SCHEMA } from "../wire/index.js"
|
|
20
|
-
import {
|
|
20
|
+
import { needsOf, runCommand, SelectorUsageError } from "./Program.js"
|
|
21
21
|
import { InMemRepo, makeCapturingCliIo, testLayers, applyEmittedScript } from "../testkit/index.js"
|
|
22
22
|
import {
|
|
23
23
|
commitAll,
|
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
} from "../emit/index.js"
|
|
29
29
|
import { HISTORY_REF } from "../platform/index.js"
|
|
30
30
|
import { CONFIG_SCAFFOLD } from "../install/index.js"
|
|
31
|
+
import { contentHash } from "../workflow/index.js"
|
|
31
32
|
|
|
32
33
|
const run = async (
|
|
33
34
|
repo: InMemRepo,
|
|
@@ -102,7 +103,7 @@ export const defaults = { greeting: "hello" }
|
|
|
102
103
|
expect(exitCode).toBe(0)
|
|
103
104
|
expect(stdout).toContain(
|
|
104
105
|
commitAll(
|
|
105
|
-
|
|
106
|
+
`gtd(human): working\n\nGtd-Workflow: hotfix\nGtd-Var: greeting=hello\nGtd-Config: ${contentHash(WORKFLOW)} gtd.config.ts\nGtd-Format: 1`,
|
|
106
107
|
),
|
|
107
108
|
)
|
|
108
109
|
expect(repo.commitHistory()).toHaveLength(before)
|
|
@@ -958,12 +959,6 @@ describe("gtd check <mode> <file>", () => {
|
|
|
958
959
|
)
|
|
959
960
|
})
|
|
960
961
|
|
|
961
|
-
it("a line-carrying finding with no range prints '<file>:<line>: <message>' — the flat-shape fallback no built-in format reaches", async () => {
|
|
962
|
-
expect(formatFinding("FAKE.md", { message: "positioned but rangeless", line: 4 })).toBe(
|
|
963
|
-
"FAKE.md:5: positioned but rangeless",
|
|
964
|
-
)
|
|
965
|
-
})
|
|
966
|
-
|
|
967
962
|
it("an absent file exits 0 with no output", async () => {
|
|
968
963
|
const repo = bareRepo()
|
|
969
964
|
const { stdout, exitCode } = await run(repo, "check", "qa", ".gtd/TODO.md")
|
package/src/cli/Program.ts
CHANGED
|
@@ -66,7 +66,7 @@ import {
|
|
|
66
66
|
openThreads,
|
|
67
67
|
parseCodeThreads,
|
|
68
68
|
unansweredQuestions,
|
|
69
|
-
|
|
69
|
+
formatFinding,
|
|
70
70
|
type SteeringFormat,
|
|
71
71
|
} from "../steering/index.js"
|
|
72
72
|
import {
|
|
@@ -851,13 +851,6 @@ const runValidateCommand = (out: ArtifactOut): Effect.Effect<void, Error, Comman
|
|
|
851
851
|
)
|
|
852
852
|
})
|
|
853
853
|
|
|
854
|
-
/** `file:line:col: message` — the shape editors and grep already jump on. Stored 0-based, printed 1-based. A finding with a `line` but no `range`, or none at all, degrades to the shorter forms. */
|
|
855
|
-
export const formatFinding = (file: string, finding: SteeringFinding): string => {
|
|
856
|
-
if (finding.line === undefined) return finding.message
|
|
857
|
-
const col = finding.range !== undefined ? `:${finding.range.start.character + 1}` : ""
|
|
858
|
-
return `${file}:${finding.line + 1}${col}: ${finding.message}`
|
|
859
|
-
}
|
|
860
|
-
|
|
861
854
|
/**
|
|
862
855
|
* Runs `<mode>`'s parser over `<file>`, exiting non-zero on any finding.
|
|
863
856
|
* Resolves no workflow state and reads no config — this is what a seeded
|
package/src/edge/Edge.test.ts
CHANGED
|
@@ -22,6 +22,7 @@ import { GTD_VERSION } from "../install/index.js"
|
|
|
22
22
|
import { processHistoryRef } from "../platform/index.js"
|
|
23
23
|
import { GitService, Workspace, type GitOperations, type WorkspaceOps } from "../platform/index.js"
|
|
24
24
|
import { formatCommitMessage, type CommitSpec } from "../replay/index.js"
|
|
25
|
+
import { contentHash } from "../workflow/index.js"
|
|
25
26
|
import {
|
|
26
27
|
fakeGitOperations,
|
|
27
28
|
InMemRepo,
|
|
@@ -201,7 +202,7 @@ describe("currentRun", () => {
|
|
|
201
202
|
const before = headOf(repo)
|
|
202
203
|
const opening = await enter(repo, "side")
|
|
203
204
|
expect(repo.lastCommitMessage()).toBe(
|
|
204
|
-
|
|
205
|
+
`gtd(human): fixing\n\nGtd-Workflow: side\nGtd-Var: base=\nGtd-Var: reviewer=nobody\nGtd-Config: ${contentHash(LINEAR)} gtd.config.ts\nGtd-Format: 1`,
|
|
205
206
|
)
|
|
206
207
|
const fixed = await land(repo, { "fix.txt": "x\n" })
|
|
207
208
|
const run = await provide(currentRun, repo)
|
|
@@ -233,7 +234,9 @@ export const again = async () => {
|
|
|
233
234
|
const repo = repoWith(STARTS_AT_INITIAL)
|
|
234
235
|
const before = headOf(repo)
|
|
235
236
|
const opening = await enter(repo, "again")
|
|
236
|
-
expect(repo.lastCommitMessage()).toBe(
|
|
237
|
+
expect(repo.lastCommitMessage()).toBe(
|
|
238
|
+
`gtd(human): idle\n\nGtd-Workflow: again\nGtd-Config: ${contentHash(STARTS_AT_INITIAL)} gtd.config.ts\nGtd-Format: 1`,
|
|
239
|
+
)
|
|
237
240
|
const fixed = await land(repo, { "fix.txt": "x\n" })
|
|
238
241
|
const run = await provide(currentRun, repo)
|
|
239
242
|
expect(run.workflow).toBe("again")
|
package/src/edge/Edge.ts
CHANGED
|
@@ -12,7 +12,6 @@ import {
|
|
|
12
12
|
ConfigDiscovery,
|
|
13
13
|
ConfigService,
|
|
14
14
|
changedConfigFiles,
|
|
15
|
-
configFingerprint,
|
|
16
15
|
contentHash,
|
|
17
16
|
multilineSetting,
|
|
18
17
|
resolveVars,
|
|
@@ -972,7 +971,7 @@ export const restAt = (
|
|
|
972
971
|
}))
|
|
973
972
|
: []
|
|
974
973
|
const memory = memoryOf(outcome.trace, run)
|
|
975
|
-
const configNow =
|
|
974
|
+
const configNow = config.fingerprint
|
|
976
975
|
// Only the current rest warns, so a command must read it once — a second
|
|
977
976
|
// read warns twice. An earlier ref (restore's tip) would warn about a
|
|
978
977
|
// process the command is not acting on.
|
|
@@ -1320,7 +1319,7 @@ const differingConfigPaths = (
|
|
|
1320
1319
|
git: GitService["Type"],
|
|
1321
1320
|
workspace: Workspace["Type"],
|
|
1322
1321
|
root: string,
|
|
1323
|
-
config: { readonly
|
|
1322
|
+
config: { readonly fingerprint: ConfigFingerprint },
|
|
1324
1323
|
run: ProcessRun,
|
|
1325
1324
|
hash: string,
|
|
1326
1325
|
) =>
|
|
@@ -1328,7 +1327,7 @@ const differingConfigPaths = (
|
|
|
1328
1327
|
const differing: string[] = []
|
|
1329
1328
|
for (const path of inRepoConfigPaths(
|
|
1330
1329
|
root,
|
|
1331
|
-
config.
|
|
1330
|
+
Object.keys(config.fingerprint),
|
|
1332
1331
|
run.pinnedConfig?.files ?? {},
|
|
1333
1332
|
)) {
|
|
1334
1333
|
const atTip = yield* Effect.orElseSucceed(git.readFileAtRef(hash, path), () => undefined)
|
package/src/emit/index.ts
CHANGED
package/src/flows/scripts.ts
CHANGED
|
@@ -118,3 +118,9 @@ export const moveScript = (from: string, to: string): string =>
|
|
|
118
118
|
`mv -- ${quote(from)} ${quote(to)}`,
|
|
119
119
|
"",
|
|
120
120
|
].join("\n")
|
|
121
|
+
|
|
122
|
+
/** Write `path` as committed at `ref` to `to`. */
|
|
123
|
+
export const showScript = (ref: string, path: string, to: string): string =>
|
|
124
|
+
["#!/usr/bin/env sh", "set -e", `git show ${quote(`${ref}:${path}`)} > ${quote(to)}`, ""].join(
|
|
125
|
+
"\n",
|
|
126
|
+
)
|
package/src/platform/Git.test.ts
CHANGED
|
@@ -39,12 +39,9 @@ describe("resolvedRefOrCorrupted", () => {
|
|
|
39
39
|
})
|
|
40
40
|
})
|
|
41
41
|
|
|
42
|
-
/**
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
describe("GitScript", () => {
|
|
49
|
-
runGitScriptContract()
|
|
50
|
-
})
|
|
42
|
+
/** `GitScript.ts`'s builders as a driver runs them: real `sh` against the in-memory twin. */
|
|
43
|
+
for (const makeTier of gitTiers) {
|
|
44
|
+
describe(`GitScript [${makeTier().name}]`, () => {
|
|
45
|
+
runGitScriptContract(makeTier)
|
|
46
|
+
})
|
|
47
|
+
}
|
package/src/steering/FORMATS.md
CHANGED
|
@@ -47,7 +47,7 @@ Breaking — committed files, or files an older gtd must read, are affected:
|
|
|
47
47
|
questions, options, chunks, hunks, ticks, threads or notes.
|
|
48
48
|
- A writer (a tick, a note, a reply) produces text an older gtd cannot read.
|
|
49
49
|
- Renaming or removing a marker: `## Open Questions`, `_your answer_`,
|
|
50
|
-
`# Review:`, `<!-- base: -->`, `Risk:`, `H:` / `A:`.
|
|
50
|
+
`# Review:`, `<!-- base: -->`, `Risk:`, a leading `! `, `H:` / `A:`.
|
|
51
51
|
|
|
52
52
|
## Format changes bump `Gtd-Format`
|
|
53
53
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest"
|
|
2
|
-
import { steeringFormatFor } from "./index.js"
|
|
2
|
+
import { formatFinding, steeringFormatFor } from "./index.js"
|
|
3
3
|
import type { SteeringFinding, SteeringFormat } from "./SteeringFormat.js"
|
|
4
4
|
|
|
5
5
|
const QA_FORMAT = steeringFormatFor("qa")!
|
|
@@ -89,3 +89,11 @@ describe("SteeringFinding range invariants", () => {
|
|
|
89
89
|
assertRangeInvariants(findings)
|
|
90
90
|
})
|
|
91
91
|
})
|
|
92
|
+
|
|
93
|
+
describe("formatFinding", () => {
|
|
94
|
+
it("a line-carrying finding with no range prints '<file>:<line>: <message>' — the flat-shape fallback no built-in format reaches", () => {
|
|
95
|
+
expect(formatFinding("FAKE.md", { message: "positioned but rangeless", line: 4 })).toBe(
|
|
96
|
+
"FAKE.md:5: positioned but rangeless",
|
|
97
|
+
)
|
|
98
|
+
})
|
|
99
|
+
})
|
|
@@ -41,6 +41,13 @@ export interface SteeringFinding {
|
|
|
41
41
|
}
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
/** `file:line:col: message` — the shape editors and grep already jump on. Stored 0-based, printed 1-based. A finding with a `line` but no `range`, or none at all, degrades to the shorter forms. */
|
|
45
|
+
export const formatFinding = (file: string, finding: SteeringFinding): string => {
|
|
46
|
+
if (finding.line === undefined) return finding.message
|
|
47
|
+
const col = finding.range !== undefined ? `:${finding.range.start.character + 1}` : ""
|
|
48
|
+
return `${file}:${finding.line + 1}${col}: ${finding.message}`
|
|
49
|
+
}
|
|
50
|
+
|
|
44
51
|
/** A hunk pointer's document-link target: `range` covers the pointer token itself; `line` is 0-based, so a `#42` suffix resolves down by one and a bare `./path` lands at 0. */
|
|
45
52
|
export interface SteeringLink {
|
|
46
53
|
readonly range: {
|