@pmelab/gtd 51.0.0 → 52.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 +41 -38
- package/dist/chunks/{reserved-CUkk9XgL.mjs → reserved-D0MKVFFa.mjs} +28 -34
- package/dist/chunks/{review-BaCXlIT2.mjs → review-ZzUQH1KZ.mjs} +11 -9
- package/dist/chunks/{steering-DtUVUz9l.mjs → steering-CLhu8C7q.mjs} +262 -52
- package/dist/chunks/{tail-CX6Pdsql.mjs → tail-I1-vVq0P.mjs} +1 -1
- package/dist/gtd.bundle.mjs +224 -68
- package/dist/steering.mjs +2 -2
- package/dist/testkit.mjs +27 -26
- package/dist/web/main.css +1 -1
- package/dist/web/main.js +510 -147
- package/dist/workflow-kit.mjs +1 -1
- package/dist/workflows/cleanup.mjs +2 -2
- package/dist/workflows/feature.mjs +181 -222
- package/dist/workflows/fix.mjs +3 -3
- package/dist/workflows/review.mjs +3 -3
- package/dist/workflows/rollout.mjs +1 -1
- package/dist/workflows.mjs +478 -313
- package/package.json +1 -1
- package/src/edge/Edge.ts +1 -30
- package/src/replay/Replay.test.ts +4 -8
- package/src/replay/Replay.ts +35 -3
- package/src/replay/index.ts +7 -1
- package/src/steering/FORMATS.md +4 -1
- package/src/steering/SteeringFormat.ts +7 -1
- package/src/steering/contract.test.ts +2 -1
- package/src/steering/fixtures/review/sectioned.md +31 -0
- package/src/steering/fixtures/review/sectioned.read.json +119 -0
- package/src/steering/index.ts +4 -0
- package/src/steering/qa.ts +1 -1
- package/src/steering/review.test.ts +270 -4
- package/src/steering/review.ts +328 -69
- package/src/workflows/feature/criteria.test.ts +39 -1
- package/src/workflows/feature/criteria.ts +21 -7
- package/src/workflows/feature/fast-path.feature +1 -6
- package/src/workflows/feature/feature.ts +1 -2
- package/src/workflows/feature/flag-mode.feature +1 -6
- package/src/workflows/feature/green.ts +22 -0
- package/src/workflows/feature/hands-on-skip.feature +344 -0
- package/src/workflows/feature/lens-models.feature +0 -4
- package/src/workflows/feature/lens-review.feature +6 -8
- package/src/workflows/feature/one-step-land.feature +1 -6
- package/src/workflows/feature/prototype-rounds.feature +1 -1
- package/src/workflows/feature/prototype.feature +10 -9
- package/src/workflows/feature/prototype.ts +48 -17
- package/src/workflows/feature/refactor-absorbs.feature +3 -8
- package/src/workflows/feature/refactor-first.feature +3 -8
- package/src/workflows/feature/refactor-rebuild.feature +7 -12
- package/src/workflows/feature/refactor-review.feature +52 -72
- package/src/workflows/feature/review-answers.feature +90 -5
- package/src/workflows/feature/review-gate.feature +11 -53
- package/src/workflows/feature/reviewer-walkthrough.feature +29 -12
- package/src/workflows/feature/seam-rejection.feature +1 -6
- package/src/workflows/feature/seam.feature +1 -6
- package/src/workflows/feature/seam.test.ts +1 -1
- package/src/workflows/feature/seam.ts +2 -3
- package/src/workflows/feature/severity-budget.feature +7 -7
- package/src/workflows/feature/severity-sorted-review.feature +50 -54
- package/src/workflows/feature/slice-findings.feature +5 -15
- package/src/workflows/feature/slicing.feature +4 -24
- package/src/workflows/feature/standards.feature +0 -4
- package/src/workflows/shared/review.ts +26 -6
- package/src/workflows/shared/text.test.ts +45 -11
- package/src/workflows/shared/text.ts +45 -38
- package/src/workflows/feature/coverage.feature +0 -165
- package/src/workflows/feature/coverage.ts +0 -120
package/package.json
CHANGED
package/src/edge/Edge.ts
CHANGED
|
@@ -37,6 +37,7 @@ import {
|
|
|
37
37
|
isRecordableApproval,
|
|
38
38
|
parseCommitMessage,
|
|
39
39
|
replay,
|
|
40
|
+
scopeRunStart,
|
|
40
41
|
sliceRanges,
|
|
41
42
|
treeFromRecord,
|
|
42
43
|
type EpisodeCommit,
|
|
@@ -791,36 +792,6 @@ const stepDefOf = (step: ReachedStep): StepDef => {
|
|
|
791
792
|
}
|
|
792
793
|
}
|
|
793
794
|
|
|
794
|
-
/** Is `name` inside `scope`'s subtree? The root scope `""` holds everything. */
|
|
795
|
-
const inScope = (name: string, scope: string): boolean =>
|
|
796
|
-
scope === "" || name === scope || name.startsWith(`${scope}.`)
|
|
797
|
-
|
|
798
|
-
/**
|
|
799
|
-
* Where the current unbroken run of rests inside `scope`'s subtree began —
|
|
800
|
-
* a dip into a descendant scope does not break it, a sibling or ancestor
|
|
801
|
-
* does, and so does a fresh `scope()` call of the same scope.
|
|
802
|
-
*/
|
|
803
|
-
const scopeRunStart = (trace: readonly ReachedStep[], scope: string): number => {
|
|
804
|
-
let start = -1
|
|
805
|
-
for (let k = 0; k < trace.length; k++) {
|
|
806
|
-
if (!inScope(trace[k]!.memoryScope, scope)) continue
|
|
807
|
-
const previous = trace[k - 1]
|
|
808
|
-
const unbroken =
|
|
809
|
-
previous !== undefined &&
|
|
810
|
-
inScope(previous.memoryScope, scope) &&
|
|
811
|
-
scopeCallOf(previous, scope) === scopeCallOf(trace[k]!, scope)
|
|
812
|
-
if (!unbroken) start = k
|
|
813
|
-
}
|
|
814
|
-
return start
|
|
815
|
-
}
|
|
816
|
-
|
|
817
|
-
/** The innermost `scope()` call enclosing `scope` at `step` — a new call is a new conversation. */
|
|
818
|
-
const scopeCallOf = (step: ReachedStep, scope: string): number | undefined => {
|
|
819
|
-
let call: number | undefined
|
|
820
|
-
for (const entry of step.scopeCalls) if (inScope(scope, entry.prefix)) call = entry.call
|
|
821
|
-
return call
|
|
822
|
-
}
|
|
823
|
-
|
|
824
795
|
// Shown for the root scope, which has no name of its own.
|
|
825
796
|
const ROOT_MEMORY_SCOPE_NAME = "root"
|
|
826
797
|
|
|
@@ -940,7 +940,7 @@ describe("replay: skills resolution", () => {
|
|
|
940
940
|
expect(await wireSkills(inScope({ name: "a" }, step), { skills: { a: [] } })).toBeUndefined()
|
|
941
941
|
})
|
|
942
942
|
|
|
943
|
-
it("
|
|
943
|
+
it("runs two scope() calls of one memory scope with different lists as two conversations", async () => {
|
|
944
944
|
const flow: Flow = async () => {
|
|
945
945
|
await scope({ name: "a", skills: ["one"] }, async () => {
|
|
946
946
|
await agent("first", "prompt")
|
|
@@ -959,9 +959,7 @@ describe("replay: skills resolution", () => {
|
|
|
959
959
|
start: hashOf(0),
|
|
960
960
|
budgetBytes: 1024,
|
|
961
961
|
})
|
|
962
|
-
expect(outcome.kind
|
|
963
|
-
"different model, system prompt, skills or file access",
|
|
964
|
-
)
|
|
962
|
+
expect(outcome.kind).toBe("rest")
|
|
965
963
|
})
|
|
966
964
|
})
|
|
967
965
|
|
|
@@ -1084,7 +1082,7 @@ describe("replay: access resolution", () => {
|
|
|
1084
1082
|
expect(outcome.rest.access).toEqual({ read: null, write: ["src/a.ts"] })
|
|
1085
1083
|
})
|
|
1086
1084
|
|
|
1087
|
-
it("
|
|
1085
|
+
it("runs two scope() calls of one memory scope with different access as two conversations", async () => {
|
|
1088
1086
|
const flow: Flow = async () => {
|
|
1089
1087
|
await scope({ name: "a", access: { write: ["one"] } }, () => agent("first", "prompt"))
|
|
1090
1088
|
await scope({ name: "a", access: { write: ["two"] } }, () => agent("second", "prompt"))
|
|
@@ -1092,9 +1090,7 @@ describe("replay: access resolution", () => {
|
|
|
1092
1090
|
const history = new History()
|
|
1093
1091
|
history.land("agent", "a.first", 1, "a.second", { "x.txt": "x" })
|
|
1094
1092
|
const outcome = await restOf(flow, {}, history)
|
|
1095
|
-
expect(outcome.kind
|
|
1096
|
-
"different model, system prompt, skills or file access",
|
|
1097
|
-
)
|
|
1093
|
+
expect(outcome.kind).toBe("rest")
|
|
1098
1094
|
})
|
|
1099
1095
|
|
|
1100
1096
|
it("does not conflict two steps that differ only in steering file", async () => {
|
package/src/replay/Replay.ts
CHANGED
|
@@ -139,6 +139,36 @@ const memoryScopeOf = (name: string): string => {
|
|
|
139
139
|
return dot === -1 ? "" : name.slice(0, dot)
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
+
/** Is `name` inside `scope`'s subtree? The root scope `""` holds everything. */
|
|
143
|
+
const inScope = (name: string, scope: string): boolean =>
|
|
144
|
+
scope === "" || name === scope || name.startsWith(`${scope}.`)
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Where the current unbroken run of rests inside `scope`'s subtree began —
|
|
148
|
+
* a dip into a descendant scope does not break it, a sibling or ancestor
|
|
149
|
+
* does, and so does a fresh `scope()` call of the same scope.
|
|
150
|
+
*/
|
|
151
|
+
export const scopeRunStart = (trace: readonly ReachedStep[], scope: string): number => {
|
|
152
|
+
let start = -1
|
|
153
|
+
for (let k = 0; k < trace.length; k++) {
|
|
154
|
+
if (!inScope(trace[k]!.memoryScope, scope)) continue
|
|
155
|
+
const previous = trace[k - 1]
|
|
156
|
+
const unbroken =
|
|
157
|
+
previous !== undefined &&
|
|
158
|
+
inScope(previous.memoryScope, scope) &&
|
|
159
|
+
scopeCallOf(previous, scope) === scopeCallOf(trace[k]!, scope)
|
|
160
|
+
if (!unbroken) start = k
|
|
161
|
+
}
|
|
162
|
+
return start
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** The innermost `scope()` call enclosing `scope` at `step` — a new call is a new conversation. */
|
|
166
|
+
const scopeCallOf = (step: ReachedStep, scope: string): number | undefined => {
|
|
167
|
+
let call: number | undefined
|
|
168
|
+
for (const entry of step.scopeCalls) if (inScope(scope, entry.prefix)) call = entry.call
|
|
169
|
+
return call
|
|
170
|
+
}
|
|
171
|
+
|
|
142
172
|
class Stop extends Error {}
|
|
143
173
|
|
|
144
174
|
const short = (hash: string): string => hash.slice(0, 7)
|
|
@@ -505,14 +535,16 @@ export const replay = async (input: ReplayInput): Promise<ReplayOutcome> => {
|
|
|
505
535
|
skills: step.skills,
|
|
506
536
|
access: unfoldedAccess.get(step),
|
|
507
537
|
}
|
|
508
|
-
|
|
538
|
+
// Keyed like the memory key: a fresh scope() call is a fresh conversation, free to differ.
|
|
539
|
+
const conversation = `${step.memoryScope}#${scopeRunStart(trace, step.memoryScope)}`
|
|
540
|
+
const seen = personas.get(conversation)
|
|
509
541
|
if (seen === undefined) {
|
|
510
|
-
personas.set(
|
|
542
|
+
personas.set(conversation, identity)
|
|
511
543
|
return undefined
|
|
512
544
|
}
|
|
513
545
|
return samePersona(seen, identity)
|
|
514
546
|
? undefined
|
|
515
|
-
: `gtd: "${step.name}" runs with a different model, system prompt, skills or file access than an earlier agent step in memory scope "${step.memoryScope || "root"}" —
|
|
547
|
+
: `gtd: "${step.name}" runs with a different model, system prompt, skills or file access than an earlier agent step in its conversation in memory scope "${step.memoryScope || "root"}" — a conversation has one identity`
|
|
516
548
|
}
|
|
517
549
|
|
|
518
550
|
const DIVERGED = "the workflow changed under this process; run `gtd abandon` to start over"
|
package/src/replay/index.ts
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
export {
|
|
1
|
+
export {
|
|
2
|
+
replay,
|
|
3
|
+
scopeRunStart,
|
|
4
|
+
type EpisodeCommit,
|
|
5
|
+
type ReachedStep,
|
|
6
|
+
type ReplayOutcome,
|
|
7
|
+
} from "./Replay.js"
|
|
2
8
|
export { treeFromRecord, type TreeView } from "./Tree.js"
|
|
3
9
|
export {
|
|
4
10
|
formatCommitMessage,
|
package/src/steering/FORMATS.md
CHANGED
|
@@ -14,7 +14,10 @@ read them.
|
|
|
14
14
|
`<!-- base: <sha> -->` comment, then `##` chunks with prose and `- [ ]` hunk
|
|
15
15
|
pointers (`./path`, `./path#start-end`), each optionally followed by
|
|
16
16
|
`— <note>`. A pointer note starting `Risk:` is a reviewer's risk. A ticked
|
|
17
|
-
pointer is a hunk the human has read.
|
|
17
|
+
pointer is a hunk the human has read. A chunk's `Tick exactly one:` list is
|
|
18
|
+
its decision. A `## Findings` or `## Walkthrough` heading groups the `###`
|
|
19
|
+
chunks under it; a walkthrough chunk takes no decision. Any other `##` is a
|
|
20
|
+
chunk of its own.
|
|
18
21
|
- **Free-form**: any markdown, for a file with no mode or an unknown one. Only
|
|
19
22
|
its threads are validated.
|
|
20
23
|
- **Threads**, shared by all three: a footnote whose definition is a list of
|
|
@@ -64,6 +64,8 @@ export interface SteeringLink {
|
|
|
64
64
|
* format-agnostic: `review`'s chunk/hunk and `qa`'s question/option are the
|
|
65
65
|
* same two things here (a container and one of its numbered children), plus
|
|
66
66
|
* `paragraph`, which a client resolves from its cursor rather than the view.
|
|
67
|
+
* A `review` finding's answer boxes are `option`s too, `questionIndex` being
|
|
68
|
+
* the chunk's index.
|
|
67
69
|
*/
|
|
68
70
|
export type SteeringAnchor =
|
|
69
71
|
| { readonly kind: "chunk"; readonly index: number }
|
|
@@ -110,7 +112,7 @@ export interface SteeringViewNode {
|
|
|
110
112
|
readonly detail?: string
|
|
111
113
|
/** A free-form status label, when the format has one (`qa`'s `"open"`/`"answered"`). */
|
|
112
114
|
readonly status?: string
|
|
113
|
-
/** `true` when this node's own condition is fully satisfied (`qa`'s answered
|
|
115
|
+
/** `true` when this node's own condition is fully satisfied (`qa`'s answered question, `review`'s decided finding) — distinct from `checked`, which is a per-item tick. */
|
|
114
116
|
readonly answered?: boolean
|
|
115
117
|
/** This node's own checkbox state, when it has one (a hunk's tick, an option's tick). */
|
|
116
118
|
readonly checked?: boolean
|
|
@@ -138,6 +140,10 @@ export interface SteeringViewNode {
|
|
|
138
140
|
readonly endLine?: number
|
|
139
141
|
/** Where `annotate` attaches a NEW note to this node. */
|
|
140
142
|
readonly anchor: SteeringAnchor
|
|
143
|
+
/** The section a top-level node sits in, when the format groups them (`review`'s `Findings` / `Walkthrough`). */
|
|
144
|
+
readonly group?: string
|
|
145
|
+
/** A `review` finding's answer boxes, in order — fix as suggested, won't fix, and the free-text slot, whose `title` is the human's text (`""` while unfilled). Kept out of `children`, which are its hunks. */
|
|
146
|
+
readonly decision?: readonly SteeringViewNode[]
|
|
141
147
|
readonly children?: readonly SteeringViewNode[]
|
|
142
148
|
/**
|
|
143
149
|
* A node's body, a SIBLING of `children` and never a reuse of it: a `qa`
|
|
@@ -97,7 +97,8 @@ describe.each(FORMATS)("the %s format", (dir, format, ticks) => {
|
|
|
97
97
|
|
|
98
98
|
if (ticks)
|
|
99
99
|
it("round-trips every tick: writing back what was read leaves the bytes unchanged", () => {
|
|
100
|
-
|
|
100
|
+
const nodes = nodesOf(format.view(content).nodes)
|
|
101
|
+
for (const node of [...nodes, ...nodes.flatMap((n) => n.decision ?? [])]) {
|
|
101
102
|
if (node.checked === undefined || node.anchor.kind === "chunk") continue
|
|
102
103
|
const anchor: SteeringAnchor = node.anchor
|
|
103
104
|
const same = write(
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# Review: 1a2b3c4
|
|
2
|
+
|
|
3
|
+
<!-- base: 1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b -->
|
|
4
|
+
|
|
5
|
+
## Findings
|
|
6
|
+
|
|
7
|
+
### A lone CR is kept
|
|
8
|
+
|
|
9
|
+
_should: contract_
|
|
10
|
+
|
|
11
|
+
The parser keeps a lone `\r` as content.
|
|
12
|
+
|
|
13
|
+
In the code:
|
|
14
|
+
|
|
15
|
+
- [x] ./src/parse.ts#30-31 — where it is kept
|
|
16
|
+
|
|
17
|
+
Tick exactly one:
|
|
18
|
+
|
|
19
|
+
- [ ] fix as suggested
|
|
20
|
+
- [x] won't fix
|
|
21
|
+
- [ ] _your answer_
|
|
22
|
+
|
|
23
|
+
## Walkthrough
|
|
24
|
+
|
|
25
|
+
### Parser accepts CRLF
|
|
26
|
+
|
|
27
|
+
- [ ] ./src/parse.ts#10-14 — normalizes `\r\n` first
|
|
28
|
+
|
|
29
|
+
## Fixed automatically
|
|
30
|
+
|
|
31
|
+
- a test name typo (0123456)
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
{
|
|
2
|
+
"header": "1a2b3c4",
|
|
3
|
+
"nodes": [
|
|
4
|
+
{
|
|
5
|
+
"title": "A lone CR is kept",
|
|
6
|
+
"detail": "The parser keeps a lone `\\r` as content. In the code:",
|
|
7
|
+
"body": [
|
|
8
|
+
{
|
|
9
|
+
"title": "The parser keeps a lone `\\r` as content.",
|
|
10
|
+
"anchor": {
|
|
11
|
+
"kind": "paragraph",
|
|
12
|
+
"line": 10
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"title": "In the code:",
|
|
17
|
+
"anchor": {
|
|
18
|
+
"kind": "paragraph",
|
|
19
|
+
"line": 12
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
],
|
|
23
|
+
"status": "should: contract",
|
|
24
|
+
"anchor": {
|
|
25
|
+
"kind": "chunk",
|
|
26
|
+
"index": 0
|
|
27
|
+
},
|
|
28
|
+
"group": "Findings",
|
|
29
|
+
"answered": true,
|
|
30
|
+
"decision": [
|
|
31
|
+
{
|
|
32
|
+
"title": "fix as suggested",
|
|
33
|
+
"checked": false,
|
|
34
|
+
"anchor": {
|
|
35
|
+
"kind": "option",
|
|
36
|
+
"questionIndex": 0,
|
|
37
|
+
"index": 0
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
"title": "won't fix",
|
|
42
|
+
"checked": true,
|
|
43
|
+
"anchor": {
|
|
44
|
+
"kind": "option",
|
|
45
|
+
"questionIndex": 0,
|
|
46
|
+
"index": 1
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
"title": "",
|
|
51
|
+
"checked": false,
|
|
52
|
+
"anchor": {
|
|
53
|
+
"kind": "option",
|
|
54
|
+
"questionIndex": 0,
|
|
55
|
+
"index": 2
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
],
|
|
59
|
+
"children": [
|
|
60
|
+
{
|
|
61
|
+
"title": "./src/parse.ts#30-31",
|
|
62
|
+
"path": "./src/parse.ts",
|
|
63
|
+
"line": 30,
|
|
64
|
+
"endLine": 31,
|
|
65
|
+
"checked": true,
|
|
66
|
+
"detail": "where it is kept",
|
|
67
|
+
"anchor": {
|
|
68
|
+
"kind": "hunk",
|
|
69
|
+
"chunkIndex": 0,
|
|
70
|
+
"index": 0
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
]
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
"title": "Parser accepts CRLF",
|
|
77
|
+
"detail": "",
|
|
78
|
+
"body": [],
|
|
79
|
+
"anchor": {
|
|
80
|
+
"kind": "chunk",
|
|
81
|
+
"index": 1
|
|
82
|
+
},
|
|
83
|
+
"group": "Walkthrough",
|
|
84
|
+
"children": [
|
|
85
|
+
{
|
|
86
|
+
"title": "./src/parse.ts#10-14",
|
|
87
|
+
"path": "./src/parse.ts",
|
|
88
|
+
"line": 10,
|
|
89
|
+
"endLine": 14,
|
|
90
|
+
"checked": false,
|
|
91
|
+
"detail": "normalizes `\\r\\n` first",
|
|
92
|
+
"anchor": {
|
|
93
|
+
"kind": "hunk",
|
|
94
|
+
"chunkIndex": 1,
|
|
95
|
+
"index": 0
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
]
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
"title": "Fixed automatically",
|
|
102
|
+
"detail": "- a test name typo (0123456)",
|
|
103
|
+
"body": [
|
|
104
|
+
{
|
|
105
|
+
"title": "a test name typo (0123456)",
|
|
106
|
+
"anchor": {
|
|
107
|
+
"kind": "paragraph",
|
|
108
|
+
"line": 30
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
],
|
|
112
|
+
"anchor": {
|
|
113
|
+
"kind": "chunk",
|
|
114
|
+
"index": 2
|
|
115
|
+
},
|
|
116
|
+
"children": []
|
|
117
|
+
}
|
|
118
|
+
]
|
|
119
|
+
}
|
package/src/steering/index.ts
CHANGED
|
@@ -16,11 +16,15 @@ export {
|
|
|
16
16
|
reviewDecisions,
|
|
17
17
|
reviewNotes,
|
|
18
18
|
reviewRisks,
|
|
19
|
+
reviewSections,
|
|
20
|
+
REVIEW_SECTIONS,
|
|
21
|
+
sectionedReview,
|
|
19
22
|
severityLine,
|
|
20
23
|
unansweredNotes,
|
|
21
24
|
type ReviewChoice,
|
|
22
25
|
type ReviewDecision,
|
|
23
26
|
type ReviewNote,
|
|
27
|
+
type ReviewSection,
|
|
24
28
|
} from "./review.js"
|
|
25
29
|
export { applySteeringEdits } from "./Edits.js"
|
|
26
30
|
|
package/src/steering/qa.ts
CHANGED
|
@@ -830,7 +830,7 @@ const replaceOptionTextEdit = (
|
|
|
830
830
|
* offset for, breaking this anchor's `apply` forever. The placeholder reads
|
|
831
831
|
* back as `""`, so an erase still reads as unanswered.
|
|
832
832
|
*/
|
|
833
|
-
const normalizeFreeTextAnswer = (text: string): string => {
|
|
833
|
+
export const normalizeFreeTextAnswer = (text: string): string => {
|
|
834
834
|
const normalized = text.replace(/\s+/g, " ").trim()
|
|
835
835
|
return normalized.length === 0 ? FREE_TEXT_PLACEHOLDER : normalized
|
|
836
836
|
}
|