@pmelab/gtd 23.2.0 → 24.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 +13 -6
- package/dist/gtd.bundle.mjs +34 -12
- package/dist/workflows.mjs +1952 -1725
- package/package.json +2 -1
- package/skills/authoring/SKILL.md +5 -4
- package/src/flows/factory.ts +5 -1
- package/src/flows/runtime.ts +3 -0
- package/src/workflow-kit/health.test.ts +30 -0
- package/src/workflow-kit/health.ts +86 -0
- package/src/workflow-kit/index.ts +7 -0
- package/src/workflow-kit/quality.test.ts +17 -0
- package/src/workflow-kit/quality.ts +45 -0
- package/src/workflow-kit/review.ts +324 -0
- package/src/{workflows → workflow-kit}/scenarios.ts +21 -28
- package/src/workflows/feature/feature.test.ts +38 -0
- package/src/workflows/{bundled.ts → feature/feature.ts} +11 -63
- package/src/workflows/{packages.test.ts → feature/packages.test.ts} +2 -2
- package/src/workflows/{packages.ts → feature/packages.ts} +3 -3
- package/src/workflows/{planning.ts → feature/planning.ts} +2 -3
- package/src/workflows/{review.test.ts → feature/review.test.ts} +9 -44
- package/src/workflows/feature/review.ts +106 -0
- package/src/workflows/feature/steps.test.ts +82 -0
- package/src/workflows/feature/steps.ts +92 -0
- package/src/workflows/fix/fix.test.ts +94 -0
- package/src/workflows/fix/fix.ts +52 -0
- package/src/workflows/index.ts +44 -2
- package/src/workflows/review/review.test.ts +43 -0
- package/src/workflows/review/review.ts +31 -0
- package/src/workflows/{access.test.ts → shared/access.test.ts} +1 -1
- package/src/workflows/{access.ts → shared/access.ts} +3 -5
- package/src/workflows/{health.test.ts → shared/health.test.ts} +1 -1
- package/src/workflows/{health.ts → shared/health.ts} +38 -55
- package/src/workflows/{options.test.ts → shared/options.test.ts} +2 -2
- package/src/workflows/{options.ts → shared/options.ts} +14 -2
- package/src/workflows/{scenarios.test.ts → shared/scenarios.test.ts} +1 -1
- package/src/workflows/shared/scenarios.ts +35 -0
- package/src/workflows/{skills.test.ts → shared/skills.test.ts} +3 -1
- package/src/workflows/{skills.ts → shared/skills.ts} +7 -6
- package/src/workflows/{steps.test.ts → shared/steps.test.ts} +1 -66
- package/src/workflows/{steps.ts → shared/steps.ts} +8 -98
- package/src/workflows/shared/tail.test.ts +205 -0
- package/src/workflows/shared/tail.ts +135 -0
- package/src/workflows/{text.fixture.ts → shared/text.fixture.ts} +2 -2
- package/src/workflows/{text.ts → shared/text.ts} +48 -7
- package/src/workflows/{threads.test.ts → shared/threads.test.ts} +1 -1
- package/src/workflows/review.ts +0 -362
- /package/src/workflows/{prose.ts → shared/prose.ts} +0 -0
- /package/src/workflows/{text.test.ts → shared/text.test.ts} +0 -0
- /package/src/workflows/{vars.ts → shared/vars.ts} +0 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pmelab/gtd",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "24.0.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Git-aware CLI that emits the next prompt for an autonomous coding agent based on the current repository state",
|
|
6
6
|
"bin": {
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
"dist/",
|
|
24
24
|
"src/flows/",
|
|
25
25
|
"src/workflows/",
|
|
26
|
+
"src/workflow-kit/",
|
|
26
27
|
"README.md",
|
|
27
28
|
"LICENSE",
|
|
28
29
|
".claude-plugin/plugin.json",
|
|
@@ -145,10 +145,11 @@ the pending landing — call it right after the step whose turn you reject.
|
|
|
145
145
|
process waits (the bundled one is `human("idle", …)`).
|
|
146
146
|
- `gtd --workflow <name>` starts a process on the exported workflow `<name>` (an
|
|
147
147
|
unknown name is a usage error); `gtd door <name> [args]` starts one through a
|
|
148
|
-
`doors` entry `{ workflow, args?, vars? }` (`gtd doors` lists them
|
|
149
|
-
`
|
|
150
|
-
`
|
|
151
|
-
|
|
148
|
+
`doors` entry `{ workflow, args?, vars? }` (`gtd doors` lists them). A
|
|
149
|
+
factory's build may return `doors` without `workflow`; exporting the bundled
|
|
150
|
+
`fix` and `review` brings `fix` and `review [base]`, and yours merge over
|
|
151
|
+
them. An `export const base = (workflow, vars) => commitish | undefined` fixes
|
|
152
|
+
a started process's diff base (blank = default-branch merge-base).
|
|
152
153
|
`--var <name>=<value>` only pins process settings: names the workflow's
|
|
153
154
|
`defaults` declare — never an environment setting.
|
|
154
155
|
|
package/src/flows/factory.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { StandardSchemaV1 } from "@standard-schema/spec"
|
|
2
|
-
import type { Flow } from "./runtime.js"
|
|
2
|
+
import type { Flow, WorkflowBase, WorkflowDoors } from "./runtime.js"
|
|
3
3
|
|
|
4
4
|
// Registered, so a factory made by any copy of this file is recognised by the
|
|
5
5
|
// engine's copy — the same reason the replay context is.
|
|
@@ -11,6 +11,10 @@ export interface WorkflowBuild {
|
|
|
11
11
|
readonly defaults?: Readonly<Record<string, string>> | undefined
|
|
12
12
|
/** Environment settings this workflow declares, over its file's `envDefaults` export. */
|
|
13
13
|
readonly envDefaults?: Readonly<Record<string, string>> | undefined
|
|
14
|
+
/** Doors onto this workflow. Two workflows building the same door is a load error unless the config's `doors` export declares that name; a declared door always wins. */
|
|
15
|
+
readonly doors?: WorkflowDoors | undefined
|
|
16
|
+
/** This workflow's diff base; the config's `base` export wins when it answers, `undefined` falls through to this. */
|
|
17
|
+
readonly base?: WorkflowBase | undefined
|
|
14
18
|
}
|
|
15
19
|
|
|
16
20
|
interface FactoryInternals {
|
package/src/flows/runtime.ts
CHANGED
|
@@ -434,3 +434,6 @@ export interface Door {
|
|
|
434
434
|
|
|
435
435
|
/** A workflow module's reserved `doors` export. */
|
|
436
436
|
export type Doors = Readonly<Record<string, Door>>
|
|
437
|
+
|
|
438
|
+
/** Doors a workflow factory declares on itself: each starts whatever name the config exports the workflow under. */
|
|
439
|
+
export type WorkflowDoors = Readonly<Record<string, Omit<Door, "workflow">>>
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import { escalation, untilSettled } from "./health.js"
|
|
3
|
+
|
|
4
|
+
describe("escalation", () => {
|
|
5
|
+
it("describes and stops until `limit` rounds are spent, then reports exhaustion", async () => {
|
|
6
|
+
const log: string[] = []
|
|
7
|
+
const steps = {
|
|
8
|
+
limit: 2,
|
|
9
|
+
describe: async () => void log.push("describe"),
|
|
10
|
+
stop: async () => void log.push("stop"),
|
|
11
|
+
exhausted: async () => void log.push("exhausted"),
|
|
12
|
+
}
|
|
13
|
+
const count = { rounds: 0 }
|
|
14
|
+
for (let i = 0; i < 3; i++) await escalation(count, steps)
|
|
15
|
+
expect(log).toEqual(["describe", "stop", "describe", "stop", "exhausted"])
|
|
16
|
+
expect(count.rounds).toBe(2)
|
|
17
|
+
})
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
describe("untilSettled", () => {
|
|
21
|
+
it("reruns while pending and resolves the first run that counts", async () => {
|
|
22
|
+
const results = [false, false, true]
|
|
23
|
+
let pending = 2
|
|
24
|
+
const green = await untilSettled(
|
|
25
|
+
async () => results.shift()!,
|
|
26
|
+
() => pending-- > 0,
|
|
27
|
+
)
|
|
28
|
+
expect(green).toBe(true)
|
|
29
|
+
})
|
|
30
|
+
})
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { read, scope } from "../flows/index.js"
|
|
2
|
+
|
|
3
|
+
/** How many escalation rounds a run of red checks has spent — reset once the suite goes green. */
|
|
4
|
+
export interface EscalationCount {
|
|
5
|
+
rounds: number
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface EscalationSteps {
|
|
9
|
+
/** Rounds a person narrows before `exhausted` replaces them. */
|
|
10
|
+
readonly limit: number
|
|
11
|
+
readonly describe: () => Promise<void>
|
|
12
|
+
readonly stop: () => Promise<void>
|
|
13
|
+
readonly exhausted: () => Promise<void>
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Hand a red suite that repeated attempts could not fix to a person, until `limit` rounds are spent. */
|
|
17
|
+
export const escalation = async (count: EscalationCount, steps: EscalationSteps): Promise<void> => {
|
|
18
|
+
if (count.rounds >= steps.limit) return steps.exhausted()
|
|
19
|
+
count.rounds++
|
|
20
|
+
await steps.describe()
|
|
21
|
+
await steps.stop()
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Run `check` again while `pending()` holds — a run then does not count — and resolve the first run that does. */
|
|
25
|
+
export const untilSettled = async (
|
|
26
|
+
check: () => Promise<boolean>,
|
|
27
|
+
pending: () => boolean,
|
|
28
|
+
): Promise<boolean> => {
|
|
29
|
+
for (;;) {
|
|
30
|
+
const green = await check()
|
|
31
|
+
if (!pending()) return green
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface Health {
|
|
36
|
+
/** One counted run of the suite; resolves `true` when green. */
|
|
37
|
+
readonly check: () => Promise<boolean>
|
|
38
|
+
/** Where `check` leaves a red run's output. */
|
|
39
|
+
readonly report: string
|
|
40
|
+
readonly fixCap: number
|
|
41
|
+
readonly fixesSoFar?: number
|
|
42
|
+
/** Whether two consecutive red reports are the same failure; `true` escalates at once. */
|
|
43
|
+
readonly sameFailure: (previous: string, current: string) => Promise<boolean>
|
|
44
|
+
/** Shared with other callers in the same run of red checks. */
|
|
45
|
+
readonly escalations: EscalationCount
|
|
46
|
+
readonly escalation: EscalationSteps
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// check() stamps the report with the commit it ran at, so equal failures differ there.
|
|
50
|
+
const STAMP = /\n<!-- gtd check [0-9a-f]+ -->\n?$/
|
|
51
|
+
const stripStamp = (text: string): string => text.replace(STAMP, "\n")
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Loop until the suite is green: check, and on red run `fix` — escalating
|
|
55
|
+
* first once `fixCap` fixes are spent, or `sameFailure` calls the failure
|
|
56
|
+
* identical to the previous round's.
|
|
57
|
+
*/
|
|
58
|
+
export const healthy = async (fix: () => Promise<void>, health: Health): Promise<void> => {
|
|
59
|
+
let fixes = health.fixesSoFar ?? 0
|
|
60
|
+
let previous: string | undefined
|
|
61
|
+
for (;;) {
|
|
62
|
+
if (await health.check()) {
|
|
63
|
+
health.escalations.rounds = 0
|
|
64
|
+
return
|
|
65
|
+
}
|
|
66
|
+
const current = stripStamp(read(health.report) ?? "")
|
|
67
|
+
const stuck = previous !== undefined && (await health.sameFailure(previous, current))
|
|
68
|
+
previous = current
|
|
69
|
+
if (stuck || fixes >= health.fixCap) {
|
|
70
|
+
await escalation(health.escalations, health.escalation)
|
|
71
|
+
fixes = 0
|
|
72
|
+
}
|
|
73
|
+
fixes++
|
|
74
|
+
await fix()
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Hold the process in scope `name` until `passes` resolves `true`, resting at `blocked` between tries. */
|
|
79
|
+
export const gate = (
|
|
80
|
+
name: string,
|
|
81
|
+
passes: () => Promise<boolean>,
|
|
82
|
+
blocked: () => Promise<void>,
|
|
83
|
+
): Promise<void> =>
|
|
84
|
+
scope(name, async () => {
|
|
85
|
+
while (!(await passes())) await blocked()
|
|
86
|
+
})
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// Building blocks the shipped workflows share. Policy-free: every skill list,
|
|
2
|
+
// lens, prompt, step, path and threshold arrives as a parameter, so a new
|
|
3
|
+
// workflow composes these without inheriting the shipped one's choices.
|
|
4
|
+
export * from "./health.js"
|
|
5
|
+
export * from "./quality.js"
|
|
6
|
+
export * from "./scenarios.js"
|
|
7
|
+
export * from "./review.js"
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import { lensesOf, lensScopes } from "./quality.js"
|
|
3
|
+
|
|
4
|
+
describe("lensesOf", () => {
|
|
5
|
+
it("splits on commas, trims, drops blanks", () => {
|
|
6
|
+
expect(lensesOf(" a ,, b,")).toEqual(["a", "b"])
|
|
7
|
+
})
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
describe("lensScopes", () => {
|
|
11
|
+
it("keys one entry per lens under the given scope", () => {
|
|
12
|
+
expect(lensScopes("x.quality", ["a", "b"], (lens) => [lens])).toEqual({
|
|
13
|
+
"x.quality.a": ["a"],
|
|
14
|
+
"x.quality.b": ["b"],
|
|
15
|
+
})
|
|
16
|
+
})
|
|
17
|
+
})
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { read } from "../flows/index.js"
|
|
2
|
+
|
|
3
|
+
/** Split a comma-separated lens list: trimmed, blanks dropped. */
|
|
4
|
+
export const lensesOf = (value: string): readonly string[] =>
|
|
5
|
+
value
|
|
6
|
+
.split(",")
|
|
7
|
+
.map((lens) => lens.trim())
|
|
8
|
+
.filter((lens) => lens.length > 0)
|
|
9
|
+
|
|
10
|
+
/** One `<scope>.<lens>` entry per lens — the per-lens part of a skills or access export. */
|
|
11
|
+
export const lensScopes = <T>(
|
|
12
|
+
scope: string,
|
|
13
|
+
lenses: readonly string[],
|
|
14
|
+
entry: (lens: string) => T,
|
|
15
|
+
): Record<string, T> => Object.fromEntries(lenses.map((lens) => [`${scope}.${lens}`, entry(lens)]))
|
|
16
|
+
|
|
17
|
+
/** One `review` turn per lens, each appending its findings to `findings`; resolves `"findings"` when that file has any. */
|
|
18
|
+
export const qualityLap = async (
|
|
19
|
+
lenses: readonly string[],
|
|
20
|
+
review: (lens: string) => Promise<void>,
|
|
21
|
+
findings: string,
|
|
22
|
+
): Promise<"clean" | "findings"> => {
|
|
23
|
+
for (const lens of lenses) await review(lens)
|
|
24
|
+
return (read(findings) ?? "").length > 0 ? "findings" : "clean"
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface FindingsFix {
|
|
28
|
+
/** One fix turn; it removes `findings` once every finding is resolved. */
|
|
29
|
+
readonly fix: () => Promise<void>
|
|
30
|
+
readonly findings: string
|
|
31
|
+
readonly cap: number
|
|
32
|
+
/** Runs once `cap` turns left findings. */
|
|
33
|
+
readonly escalate: () => Promise<void>
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Resolves `true` once `findings` is gone, `false` when `cap` turns escalated instead. */
|
|
37
|
+
export const fixFindings = async (f: FindingsFix): Promise<boolean> => {
|
|
38
|
+
for (let turns = 0; turns < f.cap; turns++) {
|
|
39
|
+
await f.fix()
|
|
40
|
+
// Not `changes()`: a wording gate after the fix would be the last step.
|
|
41
|
+
if (read(f.findings) === undefined) return true
|
|
42
|
+
}
|
|
43
|
+
await f.escalate()
|
|
44
|
+
return false
|
|
45
|
+
}
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
import {
|
|
2
|
+
changes,
|
|
3
|
+
changesSince,
|
|
4
|
+
hasThreadFor,
|
|
5
|
+
head,
|
|
6
|
+
read,
|
|
7
|
+
refuse,
|
|
8
|
+
requireReplies,
|
|
9
|
+
requireThreadsClosed,
|
|
10
|
+
type Change,
|
|
11
|
+
} from "../flows/index.js"
|
|
12
|
+
import { reviewNotes, reviewRisks, stripCodeThreads, type ReviewNote } from "../steering/index.js"
|
|
13
|
+
|
|
14
|
+
/** How a review round ended. `feedback` carries what the human edited, for the re-unwind to undo. */
|
|
15
|
+
export type ReviewOutcome =
|
|
16
|
+
| { readonly verdict: "signoff" }
|
|
17
|
+
| {
|
|
18
|
+
readonly verdict: "feedback"
|
|
19
|
+
/** The last round's commit, the re-plan base. */
|
|
20
|
+
readonly base: string
|
|
21
|
+
/** The commit the review file was written at; edits are restored from it. */
|
|
22
|
+
readonly restoreFrom: string
|
|
23
|
+
/** The code paths the rounds changed, with their content before. */
|
|
24
|
+
readonly edited: readonly Change[]
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type Verdict = "edit" | "question" | "nit" | "praise"
|
|
28
|
+
|
|
29
|
+
export type ReviewRisk = ReturnType<typeof reviewRisks>[number]
|
|
30
|
+
|
|
31
|
+
/** Everything a review round decides or says, passed in: its files, steps, judge and texts. */
|
|
32
|
+
export interface ReviewPolicy {
|
|
33
|
+
/** The review steering file the reviewer writes and the human annotates. */
|
|
34
|
+
readonly file: string
|
|
35
|
+
/** The file a collecting turn folds feedback into; its presence means feedback. */
|
|
36
|
+
readonly feedback: string
|
|
37
|
+
/** Whether a path is code, as opposed to steering state. */
|
|
38
|
+
readonly isCode: (path: string) => boolean
|
|
39
|
+
/** Write `file` over everything since `base`, carrying the answers of commit `carry` when given. */
|
|
40
|
+
readonly reviewing: (base: string, carry?: string) => Promise<void>
|
|
41
|
+
readonly fixRisks: (risks: readonly ReviewRisk[]) => Promise<void>
|
|
42
|
+
readonly keepGreen: () => Promise<void>
|
|
43
|
+
readonly awaitReview: (base: string) => Promise<void>
|
|
44
|
+
/** The round at commit `round` left no `file`. */
|
|
45
|
+
readonly reviewMissing: (round: string) => Promise<void>
|
|
46
|
+
/** Turn the round `capture` describes into `feedback`, or change nothing. */
|
|
47
|
+
readonly collecting: (capture: string) => Promise<void>
|
|
48
|
+
/** One verdict per note, keyed by note id; a note without one is an edit. */
|
|
49
|
+
readonly triage: (notes: readonly ReviewNote[]) => Promise<ReadonlyMap<string, Verdict>>
|
|
50
|
+
readonly answerQuestions: (notes: readonly ReviewNote[]) => Promise<void>
|
|
51
|
+
readonly fixNits: (notes: readonly ReviewNote[]) => Promise<void>
|
|
52
|
+
/** Remove `file` at the end of a round at commit `round`. */
|
|
53
|
+
readonly close: (round: string) => Promise<void>
|
|
54
|
+
/** What a collecting turn reads. */
|
|
55
|
+
readonly captures: {
|
|
56
|
+
readonly edits: (round: string) => string
|
|
57
|
+
readonly notes: (round: string) => string
|
|
58
|
+
readonly editNotes: (
|
|
59
|
+
round: string,
|
|
60
|
+
edits: readonly ReviewNote[],
|
|
61
|
+
answeredAt: string | undefined,
|
|
62
|
+
) => string
|
|
63
|
+
}
|
|
64
|
+
readonly refusals: {
|
|
65
|
+
/** `file` was deleted at the gate. */
|
|
66
|
+
readonly deleted: string
|
|
67
|
+
/** The collecting turn changed something without writing `feedback`. */
|
|
68
|
+
readonly unmatched: string
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const collect = async (p: ReviewPolicy, capture: string): Promise<"signoff" | "feedback"> => {
|
|
73
|
+
await p.collecting(capture)
|
|
74
|
+
if (read(p.feedback) !== undefined) return "feedback"
|
|
75
|
+
if (changes().length === 0) return "signoff"
|
|
76
|
+
return refuse(p.refusals.unmatched)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** A review with ticks, footnote markers and thread definitions removed: what is left is line notes. */
|
|
80
|
+
const notesOf = (text: string): string =>
|
|
81
|
+
text
|
|
82
|
+
.replace(/^\[\^[^\]]+\]:[^\n]*(\n(?: {2,}|\t)[^\n]*|\n(?=\s*\n(?: {2,}|\t)))*/gm, "")
|
|
83
|
+
.replace(/\[\^[^\]]+\]/g, "")
|
|
84
|
+
.replace(/\[x\]/gi, "[ ]")
|
|
85
|
+
.replace(/\s+/g, " ")
|
|
86
|
+
.trim()
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The human gate and its reply rounds: a question gets an answer inside
|
|
90
|
+
* the review file and rests at the gate again, without a lap. Resolves the
|
|
91
|
+
* commit of the last collecting turn, `"missing"` when the round left no
|
|
92
|
+
* review, or `undefined` when no such turn ran.
|
|
93
|
+
*/
|
|
94
|
+
const converse = async (
|
|
95
|
+
p: ReviewPolicy,
|
|
96
|
+
base: string,
|
|
97
|
+
collectedBefore?: string,
|
|
98
|
+
): Promise<string | undefined> => {
|
|
99
|
+
let collectedAt = collectedBefore
|
|
100
|
+
for (;;) {
|
|
101
|
+
await p.awaitReview(base)
|
|
102
|
+
if (changes(p.file).some((c) => c.status === "deleted")) refuse(p.refusals.deleted)
|
|
103
|
+
if (read(p.file) === undefined) {
|
|
104
|
+
await p.reviewMissing(head())
|
|
105
|
+
return "missing"
|
|
106
|
+
}
|
|
107
|
+
requireThreadsClosed(p.file)
|
|
108
|
+
if (!hasThreadFor("agent", p.file)) return collectedAt
|
|
109
|
+
await p.collecting(p.captures.edits(head()))
|
|
110
|
+
collectedAt = head()
|
|
111
|
+
requireReplies(p.file)
|
|
112
|
+
if (!hasThreadFor("human", p.file)) return collectedAt
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** What `finish` hands back to `review`: an outcome, or another lap at the gate. */
|
|
117
|
+
type Finish =
|
|
118
|
+
| ReviewOutcome
|
|
119
|
+
| { readonly next: "await"; readonly reviewed: string }
|
|
120
|
+
| { readonly next: "rereview"; readonly carry: string | undefined }
|
|
121
|
+
|
|
122
|
+
/** What a round's routing needs from `finish`: its closing step and the ways it ends. */
|
|
123
|
+
interface Round {
|
|
124
|
+
readonly round: string
|
|
125
|
+
readonly close: () => Promise<void>
|
|
126
|
+
readonly outcome: (verdict: "signoff" | "feedback") => ReviewOutcome
|
|
127
|
+
readonly unfolded: () => Promise<Finish>
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Judge every note, then run each verdict's route: answers, then nit fixes, then the edits' lap. */
|
|
131
|
+
const routeNotes = async (
|
|
132
|
+
p: ReviewPolicy,
|
|
133
|
+
notes: readonly ReviewNote[],
|
|
134
|
+
r: Round,
|
|
135
|
+
): Promise<Finish> => {
|
|
136
|
+
const verdicts = await p.triage(notes)
|
|
137
|
+
const of = (v: Verdict): ReviewNote[] => notes.filter((n) => (verdicts.get(n.id) ?? "edit") === v)
|
|
138
|
+
const [edits, questions, nits] = [of("edit"), of("question"), of("nit")]
|
|
139
|
+
if (edits.length + questions.length + nits.length === 0) return r.unfolded()
|
|
140
|
+
|
|
141
|
+
let answeredAt: string | undefined
|
|
142
|
+
if (questions.length > 0) {
|
|
143
|
+
await p.answerQuestions(questions)
|
|
144
|
+
answeredAt = head()
|
|
145
|
+
}
|
|
146
|
+
if (nits.length > 0) {
|
|
147
|
+
await p.fixNits(nits)
|
|
148
|
+
await p.keepGreen()
|
|
149
|
+
}
|
|
150
|
+
if (edits.length > 0) {
|
|
151
|
+
await r.close()
|
|
152
|
+
return r.outcome(await collect(p, p.captures.editNotes(r.round, edits, answeredAt)))
|
|
153
|
+
}
|
|
154
|
+
if (nits.length > 0) {
|
|
155
|
+
await r.close()
|
|
156
|
+
return { next: "rereview", carry: answeredAt }
|
|
157
|
+
}
|
|
158
|
+
return { next: "await", reviewed: answeredAt! }
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** A code edit, not a thread-only change: lines that only add, answer or remove code threads don't count. */
|
|
162
|
+
const isCodeEdit = (p: ReviewPolicy, c: Change): boolean =>
|
|
163
|
+
p.isCode(c.path) &&
|
|
164
|
+
stripCodeThreads(c.path, c.before ?? "") !== stripCodeThreads(c.path, c.after ?? "")
|
|
165
|
+
|
|
166
|
+
/** Whether anything but thread edits and ticks landed in the review file since `commit`. */
|
|
167
|
+
const notedSince = (p: ReviewPolicy, commit: string): boolean =>
|
|
168
|
+
changesSince(commit).some(
|
|
169
|
+
(c) => c.path === p.file && notesOf(c.before ?? "") !== notesOf(read(p.file) ?? ""),
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* What the in-loop collecting turn left: `folded` when it wrote `feedback`
|
|
174
|
+
* (those folds need their lap whatever is judged later), `settled` when only
|
|
175
|
+
* thread edits and ticks landed after it.
|
|
176
|
+
*/
|
|
177
|
+
const afterCollect = (
|
|
178
|
+
p: ReviewPolicy,
|
|
179
|
+
collectedAt: string | undefined,
|
|
180
|
+
): { folded: boolean; settled: boolean } =>
|
|
181
|
+
// While the collecting turn is still pending its commit has no hash, so no
|
|
182
|
+
// range can start there; the flow rests at the close before it matters.
|
|
183
|
+
collectedAt === "" ? { folded: false, settled: false } : settledAfter(p, collectedAt)
|
|
184
|
+
|
|
185
|
+
const settledAfter = (
|
|
186
|
+
p: ReviewPolicy,
|
|
187
|
+
collectedAt: string | undefined,
|
|
188
|
+
): { folded: boolean; settled: boolean } => ({
|
|
189
|
+
folded: collectedAt !== undefined && read(p.feedback) !== undefined,
|
|
190
|
+
settled:
|
|
191
|
+
collectedAt !== undefined &&
|
|
192
|
+
changesSince(collectedAt).every((c) => !isCodeEdit(p, c)) &&
|
|
193
|
+
!notedSince(p, collectedAt),
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
const finish = async (
|
|
197
|
+
p: ReviewPolicy,
|
|
198
|
+
reviewed: string,
|
|
199
|
+
collectedAt: string | undefined,
|
|
200
|
+
): Promise<Finish> => {
|
|
201
|
+
// Everything the human did is read before any agent turn runs, so a nit fix
|
|
202
|
+
// is never counted as a hand-edit.
|
|
203
|
+
const round = head()
|
|
204
|
+
const since = changesSince(reviewed)
|
|
205
|
+
const edited = since.filter((c) => isCodeEdit(p, c))
|
|
206
|
+
const baselineText = since.get(p.file)?.before ?? ""
|
|
207
|
+
const record = read(p.file) ?? ""
|
|
208
|
+
const { folded, settled } = afterCollect(p, collectedAt)
|
|
209
|
+
// The review gate clears every tick before its commit, so a changed
|
|
210
|
+
// review file is a note, never a tick.
|
|
211
|
+
const noted = since.some((c) => c.path === p.file)
|
|
212
|
+
const close = (): Promise<void> => p.close(round)
|
|
213
|
+
const outcome = (verdict: "signoff" | "feedback"): ReviewOutcome =>
|
|
214
|
+
verdict === "signoff" ? { verdict } : { verdict, base: round, restoreFrom: reviewed, edited }
|
|
215
|
+
const unfolded = async (): Promise<Finish> => {
|
|
216
|
+
await close()
|
|
217
|
+
return outcome(folded ? "feedback" : "signoff")
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const collectWith = async (capture: string): Promise<Finish> => {
|
|
221
|
+
await close()
|
|
222
|
+
return outcome(await collect(p, capture))
|
|
223
|
+
}
|
|
224
|
+
if (settled || (edited.length === 0 && !noted)) return unfolded()
|
|
225
|
+
if (edited.length > 0) return collectWith(p.captures.edits(round))
|
|
226
|
+
const notes = reviewNotes(baselineText, record)
|
|
227
|
+
if (notes.length === 0) return collectWith(p.captures.notes(round))
|
|
228
|
+
return routeNotes(p, notes, { round, close, outcome, unfolded })
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Write the review; if it marks risks, fix them, keep green, and write it again — once, so the re-review's own risks reach the human unfixed. */
|
|
232
|
+
const reviewOnce = async (
|
|
233
|
+
p: Pick<ReviewPolicy, "file" | "reviewing" | "fixRisks" | "keepGreen">,
|
|
234
|
+
base: string,
|
|
235
|
+
carry: string | undefined,
|
|
236
|
+
) => {
|
|
237
|
+
await p.reviewing(base, carry)
|
|
238
|
+
const risks = reviewRisks(read(p.file) ?? "")
|
|
239
|
+
if (risks.length === 0) return
|
|
240
|
+
await p.fixRisks(risks)
|
|
241
|
+
await p.keepGreen()
|
|
242
|
+
await p.reviewing(base, carry)
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* A reviewer writes the review over everything since `base`, a human
|
|
247
|
+
* reviews and signs off or comments, and each note is judged: edits go to
|
|
248
|
+
* another lap, questions are answered in place, nits fixed in one batch,
|
|
249
|
+
* praise dropped.
|
|
250
|
+
*/
|
|
251
|
+
export const review = async (base: string, p: ReviewPolicy): Promise<ReviewOutcome> => {
|
|
252
|
+
let carry: string | undefined
|
|
253
|
+
for (;;) {
|
|
254
|
+
await reviewOnce(p, base, carry)
|
|
255
|
+
carry = undefined
|
|
256
|
+
let reviewed = head()
|
|
257
|
+
let collectedAt: string | undefined
|
|
258
|
+
for (;;) {
|
|
259
|
+
collectedAt = await converse(p, base, collectedAt)
|
|
260
|
+
if (collectedAt === "missing") break
|
|
261
|
+
const result = await finish(p, reviewed, collectedAt)
|
|
262
|
+
if (!("next" in result)) return result
|
|
263
|
+
if (result.next === "rereview") {
|
|
264
|
+
carry = result.carry
|
|
265
|
+
break
|
|
266
|
+
}
|
|
267
|
+
reviewed = result.reviewed
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** What a review round that hands its notes on decides or says, passed in. */
|
|
273
|
+
export interface HandOffPolicy extends Pick<
|
|
274
|
+
ReviewPolicy,
|
|
275
|
+
"file" | "reviewing" | "fixRisks" | "keepGreen" | "awaitReview" | "reviewMissing" | "close"
|
|
276
|
+
> {
|
|
277
|
+
/** Keep `file` with the human's notes for another workflow; `reviewed` holds the reviewer's own version. */
|
|
278
|
+
readonly handOff: (reviewed: string) => Promise<void>
|
|
279
|
+
/** `file` was deleted at the gate. */
|
|
280
|
+
readonly deleted: string
|
|
281
|
+
/** A thread waits on the agent at the gate, and no turn here answers it. */
|
|
282
|
+
readonly threadsPending: string
|
|
283
|
+
/** The review was edited but the edit parses to no note, so `fix` would have nothing to act on. */
|
|
284
|
+
readonly unparsed: string
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* A reviewer writes the review over everything since `base` and a human
|
|
289
|
+
* reviews it. No note signs off and closes; any note ends the round with the
|
|
290
|
+
* review left in place, for another workflow to read its notes — nothing is
|
|
291
|
+
* judged or re-planned here.
|
|
292
|
+
*/
|
|
293
|
+
export const reviewToHandOff = async (base: string, p: HandOffPolicy): Promise<void> => {
|
|
294
|
+
for (;;) {
|
|
295
|
+
await reviewOnce(p, base, undefined)
|
|
296
|
+
const reviewed = head()
|
|
297
|
+
await p.awaitReview(base)
|
|
298
|
+
if (changes(p.file).some((c) => c.status === "deleted")) refuse(p.deleted)
|
|
299
|
+
const record = read(p.file)
|
|
300
|
+
if (record === undefined) {
|
|
301
|
+
await p.reviewMissing(head())
|
|
302
|
+
continue
|
|
303
|
+
}
|
|
304
|
+
// `fix` runs in a new process, where a code thread is no longer "changed"
|
|
305
|
+
// and nothing answers it — so none may be left waiting on the agent.
|
|
306
|
+
requireThreadsClosed(p.file)
|
|
307
|
+
if (hasThreadFor("agent", p.file)) refuse(p.threadsPending)
|
|
308
|
+
const since = changesSince(reviewed)
|
|
309
|
+
const before = since.get(p.file)?.before ?? record
|
|
310
|
+
if (notesOf(before) === notesOf(record)) {
|
|
311
|
+
// A hand edit in a sign-off round was never checked or reviewed.
|
|
312
|
+
if (since.paths.some((f) => !f.startsWith(".gtd/"))) {
|
|
313
|
+
await p.keepGreen()
|
|
314
|
+
}
|
|
315
|
+
await p.close(head())
|
|
316
|
+
return
|
|
317
|
+
}
|
|
318
|
+
// `fix` acts only on parsed notes: an edit with none (a deleted chunk,
|
|
319
|
+
// stray prose) would be closed away unread, so it is refused here.
|
|
320
|
+
if (reviewNotes(before, record).length === 0) refuse(p.unparsed)
|
|
321
|
+
await p.handOff(reviewed)
|
|
322
|
+
return
|
|
323
|
+
}
|
|
324
|
+
}
|
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import { head,
|
|
2
|
-
import * as t from "./text.js"
|
|
1
|
+
import { head, read } from "../flows/index.js"
|
|
3
2
|
|
|
4
3
|
/** Mutable: an accepted change moves the snapshot forward. */
|
|
5
4
|
export interface FrozenScenarios {
|
|
@@ -93,26 +92,33 @@ const lineDiff = (a: readonly string[], b: readonly string[]): string[] => {
|
|
|
93
92
|
return [...out, ...a.slice(i).map((l) => `- ${l}`), ...b.slice(j).map((l) => `+ ${l}`)]
|
|
94
93
|
}
|
|
95
94
|
|
|
96
|
-
export
|
|
95
|
+
export interface WordingDrift {
|
|
96
|
+
readonly path: string
|
|
97
|
+
/** `- `/`+ `-prefixed, in file order. */
|
|
98
|
+
readonly lines: readonly string[]
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export const wordingDrift = (frozen: FrozenScenarios, path: string): WordingDrift => ({
|
|
97
102
|
path,
|
|
98
103
|
lines: lineDiff(lines(frozen.texts[path]), lines(textOf(path))),
|
|
99
104
|
})
|
|
100
105
|
|
|
101
|
-
/**
|
|
102
|
-
|
|
106
|
+
/**
|
|
107
|
+
* After a turn: rest at `stop` while wording drifted from `frozen`. Whatever
|
|
108
|
+
* still differs after it is accepted and moves the snapshot; what was
|
|
109
|
+
* restored stays frozen as it was.
|
|
110
|
+
*/
|
|
111
|
+
export const holdWording = async (
|
|
112
|
+
frozen: FrozenScenarios | undefined,
|
|
113
|
+
stop: (at: string, drifted: readonly WordingDrift[]) => Promise<void>,
|
|
114
|
+
): Promise<void> => {
|
|
103
115
|
if (frozen === undefined) return
|
|
104
116
|
const drifted = driftedScenarios(frozen)
|
|
105
117
|
if (drifted.length === 0) return
|
|
106
|
-
await
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
),
|
|
111
|
-
label: "Scenario wording changed",
|
|
112
|
-
acceptClean: true,
|
|
113
|
-
base: frozen.at,
|
|
114
|
-
})
|
|
115
|
-
// Still differing is accepted; restored is rejected and stays frozen as it was.
|
|
118
|
+
await stop(
|
|
119
|
+
frozen.at,
|
|
120
|
+
drifted.map((path) => wordingDrift(frozen, path)),
|
|
121
|
+
)
|
|
116
122
|
const accepted = driftedScenarios(frozen)
|
|
117
123
|
if (accepted.length === 0) return
|
|
118
124
|
for (const path of accepted) {
|
|
@@ -122,16 +128,3 @@ export const holdWording = async (frozen: FrozenScenarios | undefined): Promise<
|
|
|
122
128
|
}
|
|
123
129
|
frozen.at = head()
|
|
124
130
|
}
|
|
125
|
-
|
|
126
|
-
/** `turn`, then `holdWording`; `within` names the scope the gate rests in. */
|
|
127
|
-
export const guarded =
|
|
128
|
-
(
|
|
129
|
-
frozen: FrozenScenarios | undefined,
|
|
130
|
-
turn: () => Promise<void>,
|
|
131
|
-
within?: string,
|
|
132
|
-
): (() => Promise<void>) =>
|
|
133
|
-
async () => {
|
|
134
|
-
await turn()
|
|
135
|
-
if (within === undefined) await holdWording(frozen)
|
|
136
|
-
else await scope(within, () => holdWording(frozen))
|
|
137
|
-
}
|