@pmelab/gtd 26.0.0 → 27.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 +21 -3
- package/dist/chunks/{Schema-B5yK7QrT.mjs → Schema-CyImoHTj.mjs} +56 -3
- package/dist/chunks/{reserved-6pAgFGgB.mjs → reserved-BL9krPao.mjs} +1 -1
- package/dist/chunks/{wire-DA7q2ikx.mjs → wire-BY1S-obW.mjs} +5 -2
- package/dist/flows.mjs +15 -1
- package/dist/gtd.bundle.mjs +219 -34
- package/dist/testkit.mjs +447 -72
- package/dist/wire.mjs +2 -2
- package/dist/workflows/feature.mjs +1 -1
- package/dist/workflows/fix.mjs +1 -1
- package/dist/workflows/review.mjs +1 -1
- package/package.json +2 -2
- package/src/cli/Cli.test.ts +56 -4
- package/src/cli/Cli.ts +48 -6
- package/src/cli/History.test.ts +2 -2
- package/src/cli/Output.ts +3 -1
- package/src/cli/Program.test.ts +11 -12
- package/src/cli/Program.ts +28 -7
- package/src/cli/Strays.test.ts +88 -0
- package/src/cli/Strays.ts +56 -0
- package/src/edge/Edge.test.ts +113 -16
- package/src/edge/Edge.ts +22 -6
- package/src/emit/GitScript.test.ts +65 -2
- package/src/emit/GitScript.ts +34 -1
- package/src/emit/index.ts +1 -0
- package/src/flags/index.ts +0 -1
- package/src/flows/runtime.test.ts +33 -0
- package/src/flows/runtime.ts +31 -0
- package/src/platform/Git.test.ts +96 -1
- package/src/platform/Git.ts +24 -0
- package/src/platform/RetainedHistory.test.ts +9 -0
- package/src/platform/RetainedHistory.ts +3 -0
- package/src/platform/Workspace.test.ts +1 -1
- package/src/platform/index.ts +1 -0
- package/src/replay/RefName.test.ts +40 -0
- package/src/replay/RefName.ts +19 -0
- package/src/replay/Replay.test.ts +193 -2
- package/src/replay/Replay.ts +51 -6
- package/src/replay/Trailers.test.ts +26 -0
- package/src/replay/Trailers.ts +8 -0
- package/src/replay/index.ts +1 -0
- package/src/step/LandStep.ts +1 -0
- package/src/step/RepoSnapshot.ts +2 -0
- package/src/step/index.ts +1 -0
- package/src/step/planStart.test.ts +5 -5
- package/src/step/planStep.test.ts +53 -0
- package/src/step/planStep.ts +7 -1
- package/src/step/strayPaths.test.ts +17 -0
- package/src/step/strayPaths.ts +8 -0
- package/src/testkit/EmittedScriptRecognizer.test.ts +57 -29
- package/src/testkit/EmittedScriptRecognizer.ts +26 -4
- package/src/testkit/EmittedScripts.test.ts +182 -0
- package/src/testkit/FakeGitOperations.ts +10 -2
- package/src/testkit/GitTiers.ts +170 -25
- package/src/testkit/InMemRepo.ts +64 -12
- package/src/testkit/Layers.test.ts +34 -1
- package/src/testkit/Layers.ts +21 -1
- package/src/testkit/TestRepo.ts +3 -3
- package/src/wire/ExitCodes.test.ts +11 -3
- package/src/wire/ExitCodes.ts +3 -0
- package/src/wire/Glob.test.ts +35 -0
- package/src/wire/Glob.ts +1 -1
- package/src/workflow/Workflow.ts +1 -0
- package/src/workflow/discovery.test.ts +1 -1
- package/src/workflows/shared/text.fixture.ts +1 -0
package/dist/wire.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { C as EXIT_SIGTERM, D as
|
|
2
|
-
export { ACCESS_REFUSAL, BEAT_KINDS, DEMAND_BRIEFING, EXIT_CODES, EXIT_OK, EXIT_RUNTIME_ERROR, EXIT_SIGINT, EXIT_SIGTERM, EXIT_USAGE_ERROR, UNATTRIBUTED_MODEL, WIRE_SCHEMA, beatDocument, beatKindOf, demandOf, globMatches, judgeJson, landFields, landProseText, noopText, renderBeatJson, renderBeatPlain, renderFormat, renderLandJson, renderLandPlain, resolveSession, selectPath, stallDiagnosis, statusOf };
|
|
1
|
+
import { C as EXIT_SIGTERM, D as UNATTRIBUTED_MODEL, E as ACCESS_REFUSAL, O as WIRE_SCHEMA, S as EXIT_SIGINT, T as EXIT_USAGE_ERROR, _ as demandOf, a as landFields, b as EXIT_OK, c as renderLandJson, d as renderBeatPlain, f as beatDocument, g as beatKindOf, h as BEAT_KINDS, i as judgeJson, k as renderFormat, l as renderLandPlain, m as statusOf, n as resolveSession, o as landProseText, p as renderBeatJson, r as selectPath, s as noopText, t as globMatches, u as DEMAND_BRIEFING, v as stallDiagnosis, w as EXIT_STRAYS_FOUND, x as EXIT_RUNTIME_ERROR, y as EXIT_CODES } from "./chunks/wire-BY1S-obW.mjs";
|
|
2
|
+
export { ACCESS_REFUSAL, BEAT_KINDS, DEMAND_BRIEFING, EXIT_CODES, EXIT_OK, EXIT_RUNTIME_ERROR, EXIT_SIGINT, EXIT_SIGTERM, EXIT_STRAYS_FOUND, EXIT_USAGE_ERROR, UNATTRIBUTED_MODEL, WIRE_SCHEMA, beatDocument, beatKindOf, demandOf, globMatches, judgeJson, landFields, landProseText, noopText, renderBeatJson, renderBeatPlain, renderFormat, renderLandJson, renderLandPlain, resolveSession, selectPath, stallDiagnosis, statusOf };
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { answered, changes, changesSince, glob, hasThreadFor, head, human, judge, numeric, read, refuse, removeScript, requireAnswers, requireProgress, requireReplies, requireRevert, requireThreadsClosed, restoreScript, revertScript, run, scope, start, vars } from "../flows.mjs";
|
|
2
2
|
import { review as review$1 } from "../workflow-kit.mjs";
|
|
3
|
-
import { $ as unwindFailedMessage, A as architectureDecomposePrompt, B as designSystem, C as fixRisks, D as agentWithSkills, E as reviewing, F as buildReviewFixNitsPrompt, G as packagesItemBuildingPrompt, I as buildReviewTriageMessage, J as reviewEditsCapture, K as packagesItemFixSuitePrompt, L as builderSystem, M as buildReviewAnswerQuestionsPrompt, N as buildReviewAwaitAfterDeferMessage, O as architectSystem, P as buildReviewCollectingPrompt, Q as startGateBlockedMessage, R as deferNotesScript, S as fixCheck, T as reviewMissing, V as designTriagePrompt, W as idleMessage, X as reviewNotesCapture, Z as reviewerSystem, _ as FEEDBACK, b as awaitReview, c as refusePendingHandOff, d as defaults, et as unwindFailure, f as envDefaults, g as ARCHITECTURE, h as healthy, i as skills, j as architectureGateAnswerMessage, k as architectureAuthorPrompt, l as tail, m as gate, n as summary, o as closeReview, q as reviewEditNotesCapture, r as access, t as steering, u as shipped, v as REQUIREMENTS, w as planner, x as coder, y as REVIEW, z as designGateAnswerMessage } from "../chunks/reserved-
|
|
3
|
+
import { $ as unwindFailedMessage, A as architectureDecomposePrompt, B as designSystem, C as fixRisks, D as agentWithSkills, E as reviewing, F as buildReviewFixNitsPrompt, G as packagesItemBuildingPrompt, I as buildReviewTriageMessage, J as reviewEditsCapture, K as packagesItemFixSuitePrompt, L as builderSystem, M as buildReviewAnswerQuestionsPrompt, N as buildReviewAwaitAfterDeferMessage, O as architectSystem, P as buildReviewCollectingPrompt, Q as startGateBlockedMessage, R as deferNotesScript, S as fixCheck, T as reviewMissing, V as designTriagePrompt, W as idleMessage, X as reviewNotesCapture, Z as reviewerSystem, _ as FEEDBACK, b as awaitReview, c as refusePendingHandOff, d as defaults, et as unwindFailure, f as envDefaults, g as ARCHITECTURE, h as healthy, i as skills, j as architectureGateAnswerMessage, k as architectureAuthorPrompt, l as tail, m as gate, n as summary, o as closeReview, q as reviewEditNotesCapture, r as access, t as steering, u as shipped, v as REQUIREMENTS, w as planner, x as coder, y as REVIEW, z as designGateAnswerMessage } from "../chunks/reserved-BL9krPao.mjs";
|
|
4
4
|
//#region src/workflows/feature/steps.ts
|
|
5
5
|
/** Turn the sketch since `base` into `.gtd/REQUIREMENTS.md`. */
|
|
6
6
|
const triage = (base) => agentWithSkills("triage", designTriagePrompt(base), {
|
package/dist/workflows/fix.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { read, refuse, removeScript, run, scope, start } from "../flows.mjs";
|
|
2
2
|
import { d as reviewNotes, f as reviewRisks } from "../chunks/steering-DVILanVf.mjs";
|
|
3
|
-
import { D as agentWithSkills, H as finisherSystem, U as fixReviewNotesPrompt, a as REVIEW_ORIG, d as defaults, f as envDefaults, i as skills, n as summary, p as baseline, r as access, s as handOffTail, t as steering, u as shipped, x as coder, y as REVIEW } from "../chunks/reserved-
|
|
3
|
+
import { D as agentWithSkills, H as finisherSystem, U as fixReviewNotesPrompt, a as REVIEW_ORIG, d as defaults, f as envDefaults, i as skills, n as summary, p as baseline, r as access, s as handOffTail, t as steering, u as shipped, x as coder, y as REVIEW } from "../chunks/reserved-BL9krPao.mjs";
|
|
4
4
|
//#region src/workflows/fix/fix.ts
|
|
5
5
|
/**
|
|
6
6
|
* Fix every note a human left in a handed-off `.gtd/REVIEW.md` and every
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { start } from "../flows.mjs";
|
|
2
|
-
import { Y as reviewGateBlockedMessage, c as refusePendingHandOff, d as defaults, f as envDefaults, i as skills, m as gate, n as summary, r as access, s as handOffTail, t as steering, u as shipped } from "../chunks/reserved-
|
|
2
|
+
import { Y as reviewGateBlockedMessage, c as refusePendingHandOff, d as defaults, f as envDefaults, i as skills, m as gate, n as summary, r as access, s as handOffTail, t as steering, u as shipped } from "../chunks/reserved-BL9krPao.mjs";
|
|
3
3
|
//#region src/workflows/review/review.ts
|
|
4
4
|
/**
|
|
5
5
|
* Pure review of everything since `reviewBase`: a green gate, the quality
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pmelab/gtd",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "27.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": {
|
|
@@ -106,7 +106,7 @@
|
|
|
106
106
|
"postbuild": "node scripts/assert-no-test-doubles.mjs",
|
|
107
107
|
"watch": "tsdown --watch",
|
|
108
108
|
"bench:replay": "node scripts/bench-replay.mjs",
|
|
109
|
-
"test": "
|
|
109
|
+
"test": "jiti scripts/test-states.main.ts",
|
|
110
110
|
"test:unit": "vitest run --project unit",
|
|
111
111
|
"test:workflows": "vitest run --project workflows",
|
|
112
112
|
"test:watch": "vitest",
|
package/src/cli/Cli.test.ts
CHANGED
|
@@ -981,9 +981,60 @@ describe("parseArgv — gtd history", () => {
|
|
|
981
981
|
})
|
|
982
982
|
})
|
|
983
983
|
|
|
984
|
+
describe("parseArgv — gtd strays", () => {
|
|
985
|
+
const strays = (...args: string[]): CliPlan => parseArgv(["node", "gtd.js", "strays", ...args])
|
|
986
|
+
|
|
987
|
+
it("splits the range at '..' and carries the globs", () => {
|
|
988
|
+
const plan = strays("main..feature", "src/**", "*.md")
|
|
989
|
+
expect(plan).toMatchObject({
|
|
990
|
+
kind: "command",
|
|
991
|
+
command: { kind: "strays", base: "main", tip: "feature", globs: ["src/**", "*.md"] },
|
|
992
|
+
})
|
|
993
|
+
})
|
|
994
|
+
|
|
995
|
+
it("an empty side becomes HEAD; no globs is an empty list", () => {
|
|
996
|
+
expect(strays("main..")).toMatchObject({
|
|
997
|
+
command: { base: "main", tip: "HEAD", globs: [] },
|
|
998
|
+
})
|
|
999
|
+
expect(strays("..main")).toMatchObject({ command: { base: "HEAD", tip: "main" } })
|
|
1000
|
+
expect(strays("..")).toMatchObject({ command: { base: "HEAD", tip: "HEAD" } })
|
|
1001
|
+
})
|
|
1002
|
+
|
|
1003
|
+
it("a missing range, a range without '..', a '...' range and a second '..' are usage errors", () => {
|
|
1004
|
+
for (const args of [[], ["main"], ["main...feature"], ["a..b..c"]]) {
|
|
1005
|
+
expect(strays(...args).kind).toBe("usage")
|
|
1006
|
+
}
|
|
1007
|
+
const plan = strays("main")
|
|
1008
|
+
if (plan.kind === "usage") expect(plan.message).toContain("<base>..<tip>")
|
|
1009
|
+
})
|
|
1010
|
+
|
|
1011
|
+
it("--json is in scope: bare and selected", () => {
|
|
1012
|
+
expect(strays("a..b", "--json")).toMatchObject({ kind: "command", json: { kind: "document" } })
|
|
1013
|
+
expect(strays("a..b", "--json=0")).toMatchObject({
|
|
1014
|
+
kind: "command",
|
|
1015
|
+
json: { kind: "select", path: "0" },
|
|
1016
|
+
})
|
|
1017
|
+
})
|
|
1018
|
+
|
|
1019
|
+
it("--json's scope error and help name strays", () => {
|
|
1020
|
+
const plan = parseArgv(["node", "gtd.js", "lsp", "--json"])
|
|
1021
|
+
if (plan.kind === "usage") expect(plan.message).toContain("`gtd strays`")
|
|
1022
|
+
expect(renderHelp()).toContain("strays <range> [glob...]")
|
|
1023
|
+
expect(renderHelp()).toContain("gtd strays only")
|
|
1024
|
+
})
|
|
1025
|
+
})
|
|
1026
|
+
|
|
984
1027
|
describe("standaloneKinds / needsOf", () => {
|
|
985
1028
|
it("pins the standalone kinds", () => {
|
|
986
|
-
expect(standaloneKinds()).toEqual([
|
|
1029
|
+
expect(standaloneKinds()).toEqual([
|
|
1030
|
+
"lsp",
|
|
1031
|
+
"check",
|
|
1032
|
+
"uncheck",
|
|
1033
|
+
"install",
|
|
1034
|
+
"loop",
|
|
1035
|
+
"judgeRun",
|
|
1036
|
+
"strays",
|
|
1037
|
+
])
|
|
987
1038
|
})
|
|
988
1039
|
|
|
989
1040
|
it("needsOf matches none/fs for the standalone kinds and state for everything else", () => {
|
|
@@ -993,6 +1044,7 @@ describe("standaloneKinds / needsOf", () => {
|
|
|
993
1044
|
expect(needsOf("install")).toBe("none")
|
|
994
1045
|
expect(needsOf("loop")).toBe("none")
|
|
995
1046
|
expect(needsOf("judgeRun")).toBe("none")
|
|
1047
|
+
expect(needsOf("strays")).toBe("none")
|
|
996
1048
|
for (const kind of [
|
|
997
1049
|
"land",
|
|
998
1050
|
"start",
|
|
@@ -1231,7 +1283,7 @@ describe("runCli — exit codes", () => {
|
|
|
1231
1283
|
const repo = new InMemRepo()
|
|
1232
1284
|
repo.writeFile("NOTE.md", "a note\n")
|
|
1233
1285
|
repo.writeFile("gtd.config.ts", CONFIG_SCAFFOLD)
|
|
1234
|
-
repo.
|
|
1286
|
+
repo.seedCommit("gtd: init")
|
|
1235
1287
|
const { io, captured } = capturingIo(() => testLayers(repo))
|
|
1236
1288
|
await Effect.runPromise(runCli(["node", "gtd.js", "next", "--json=does.not.exist"], io))
|
|
1237
1289
|
const { stdout, stderr, exitCode } = captured()
|
|
@@ -1316,7 +1368,7 @@ describe("runCli — stdout stays byte-empty on every failing surface", () => {
|
|
|
1316
1368
|
// produce zero calls, not merely an empty joined string.
|
|
1317
1369
|
const repo = new InMemRepo()
|
|
1318
1370
|
repo.writeFile("README.md", "x\n")
|
|
1319
|
-
repo.
|
|
1371
|
+
repo.seedCommit("chore: init gtd workflow")
|
|
1320
1372
|
repo.writeFile(".gtd/TODO.md", "## Open Questions\n\n###\n\nno question text.\n")
|
|
1321
1373
|
const stdoutCalls: string[] = []
|
|
1322
1374
|
const io: CliIo = {
|
|
@@ -1396,7 +1448,7 @@ describe("runCli — flush normalizes the artifact's trailing newline", () => {
|
|
|
1396
1448
|
const repo = new InMemRepo()
|
|
1397
1449
|
repo.writeFile(".gitignore", "node_modules\n")
|
|
1398
1450
|
repo.writeFile("gtd.config.ts", CONFIG_SCAFFOLD)
|
|
1399
|
-
repo.
|
|
1451
|
+
repo.seedCommit("chore: initial commit")
|
|
1400
1452
|
const { io, captured } = capturingIo(() => testLayers(repo))
|
|
1401
1453
|
await Effect.runPromise(runCli(["node", "gtd.js", "land"], io))
|
|
1402
1454
|
const { stdout } = captured()
|
package/src/cli/Cli.ts
CHANGED
|
@@ -29,7 +29,7 @@ import {
|
|
|
29
29
|
type JsonMode,
|
|
30
30
|
} from "./Program.js"
|
|
31
31
|
import type { ToolCommands } from "./ToolCommands.js"
|
|
32
|
-
import {
|
|
32
|
+
import { EXIT_RUNTIME_ERROR, EXIT_USAGE_ERROR } from "../wire/index.js"
|
|
33
33
|
|
|
34
34
|
export type { Command, CommandRequirements } from "./Program.js"
|
|
35
35
|
export { needsOf, standaloneKinds } from "./Program.js"
|
|
@@ -96,15 +96,16 @@ const FLAGS: readonly FlagRow[] = [
|
|
|
96
96
|
kind === "judgeAnswer" ||
|
|
97
97
|
kind === "doors" ||
|
|
98
98
|
kind === "historyList" ||
|
|
99
|
-
kind === "historyShow"
|
|
99
|
+
kind === "historyShow" ||
|
|
100
|
+
kind === "strays",
|
|
100
101
|
decode: ([raw]) => Either.right(raw ?? ""),
|
|
101
102
|
scopeError:
|
|
102
|
-
"gtd: --json is only valid for `gtd next`/`gtd land`/`gtd judge`/`gtd judge answer`/`gtd doors`/`gtd history list`/`gtd history show` — " +
|
|
103
|
+
"gtd: --json is only valid for `gtd next`/`gtd land`/`gtd judge`/`gtd judge answer`/`gtd doors`/`gtd history list`/`gtd history show`/`gtd strays` — " +
|
|
103
104
|
"every other command prints plain text; see `gtd install` for the driver protocol briefing",
|
|
104
105
|
valueHint: "<path>",
|
|
105
106
|
help: [
|
|
106
107
|
"(gtd next/gtd land/gtd judge/gtd judge answer/gtd doors/gtd history",
|
|
107
|
-
"list/gtd history show only) output structured JSON. Bare --json prints the whole document;",
|
|
108
|
+
"list/gtd history show/gtd strays only) output structured JSON. Bare --json prints the whole document;",
|
|
108
109
|
"--json=<path> (a dotted key path into that document, e.g.",
|
|
109
110
|
"kind, content, session.id) prints just that value: a",
|
|
110
111
|
"scalar raw and unquoted, a boolean as true/false, a list",
|
|
@@ -667,6 +668,25 @@ const COMMAND_ROWS: readonly CommandRow[] = [
|
|
|
667
668
|
"every question, with nothing on stdout",
|
|
668
669
|
],
|
|
669
670
|
},
|
|
671
|
+
{
|
|
672
|
+
token: "strays",
|
|
673
|
+
kind: "strays",
|
|
674
|
+
arity: { name: "range", variadic: "glob" },
|
|
675
|
+
details: [
|
|
676
|
+
"List the files <range> (<base>..<tip>, an empty side is HEAD)",
|
|
677
|
+
"touched in its committed net diff that match none of the",
|
|
678
|
+
"globs. Anchored at the repo root: * stays within one path",
|
|
679
|
+
"segment, ** crosses them, everything else is literal — quote",
|
|
680
|
+
"globs in the shell. stdout: a sorted JSON array, [] when",
|
|
681
|
+
"clean; stderr: a summary, plus one stray: line per path with",
|
|
682
|
+
"--verbose. --json=<n> picks one entry, nothing past the end;",
|
|
683
|
+
"a symlink is a path like any other, never followed; a",
|
|
684
|
+
"submodule counts as its path, whatever diff.ignoreSubmodules",
|
|
685
|
+
"or .gitmodules ignore say. Runs in any git repository, no",
|
|
686
|
+
"process needed. Exits 10 when",
|
|
687
|
+
"strays were found, 1 on an unknown revision, 2 on a bad range",
|
|
688
|
+
],
|
|
689
|
+
},
|
|
670
690
|
{
|
|
671
691
|
token: "history list",
|
|
672
692
|
kind: "historyList",
|
|
@@ -1228,6 +1248,28 @@ export const parseArgv = (argv: readonly string[]): CliPlan => {
|
|
|
1228
1248
|
}
|
|
1229
1249
|
}
|
|
1230
1250
|
|
|
1251
|
+
if (kind === "strays") {
|
|
1252
|
+
const [range, ...globs] = restPositionals as [string, ...string[]]
|
|
1253
|
+
const at = range.indexOf("..")
|
|
1254
|
+
if (at < 0 || range.includes("...") || range.indexOf("..", at + 1) >= 0) {
|
|
1255
|
+
return usagePlan(
|
|
1256
|
+
`gtd strays: <range> must be <base>..<tip> (an empty side is HEAD) — got '${range}'`,
|
|
1257
|
+
jsonSeen,
|
|
1258
|
+
)
|
|
1259
|
+
}
|
|
1260
|
+
return {
|
|
1261
|
+
kind: "command",
|
|
1262
|
+
command: {
|
|
1263
|
+
kind: "strays",
|
|
1264
|
+
base: range.slice(0, at) || "HEAD",
|
|
1265
|
+
tip: range.slice(at + 2) || "HEAD",
|
|
1266
|
+
globs,
|
|
1267
|
+
},
|
|
1268
|
+
json,
|
|
1269
|
+
verbose,
|
|
1270
|
+
}
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1231
1273
|
if (kind === "uncheck") {
|
|
1232
1274
|
return {
|
|
1233
1275
|
kind: "command",
|
|
@@ -1419,9 +1461,9 @@ export const runCli = (argv: readonly string[], io: CliIo): Effect.Effect<void,
|
|
|
1419
1461
|
// where the whole buffered artifact is known-complete and safe to hand
|
|
1420
1462
|
// to stdout (a failure discards the buffer instead — see
|
|
1421
1463
|
// `bufferedArtifactOut`).
|
|
1422
|
-
Effect.map(() => {
|
|
1464
|
+
Effect.map((code) => {
|
|
1423
1465
|
out.flush()
|
|
1424
|
-
io.exit(
|
|
1466
|
+
io.exit(code)
|
|
1425
1467
|
}),
|
|
1426
1468
|
Effect.provide(io.layers(plan.verbose)),
|
|
1427
1469
|
Effect.sandbox,
|
package/src/cli/History.test.ts
CHANGED
|
@@ -36,7 +36,7 @@ const finishedProcess = async (repo: InMemRepo, file: string) => {
|
|
|
36
36
|
const repoWithConfig = (): InMemRepo => {
|
|
37
37
|
const repo = new InMemRepo()
|
|
38
38
|
repo.writeFile("gtd.config.ts", CONFIG)
|
|
39
|
-
repo.
|
|
39
|
+
repo.seedCommit("chore: add workflow")
|
|
40
40
|
return repo
|
|
41
41
|
}
|
|
42
42
|
|
|
@@ -90,7 +90,7 @@ describe("historyEntries", () => {
|
|
|
90
90
|
const repo = repoWithConfig()
|
|
91
91
|
const { entry: hash } = await finishedProcess(repo, "a.txt")
|
|
92
92
|
repo.writeFile("x.txt", "x\n")
|
|
93
|
-
repo.
|
|
93
|
+
repo.seedCommit("chore: bare tip")
|
|
94
94
|
repo.updateRef(`refs/gtd/history/${hash}`, "HEAD")
|
|
95
95
|
expect((await entries(repo))[0]?.branch).toBeNull()
|
|
96
96
|
})
|
package/src/cli/Output.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Effect } from "effect"
|
|
2
2
|
import { CommandRunner, GitService, Host, Narrator, Workspace } from "../platform/index.js"
|
|
3
|
-
import { selectPath } from "../wire/index.js"
|
|
3
|
+
import { selectPath, type EXIT_OK, type EXIT_STRAYS_FOUND } from "../wire/index.js"
|
|
4
4
|
import { ConfigDiscovery, ConfigService } from "../workflow/index.js"
|
|
5
5
|
import type { ToolCommands } from "./ToolCommands.js"
|
|
6
6
|
|
|
@@ -66,3 +66,5 @@ export type CommandRequirements =
|
|
|
66
66
|
| Narrator
|
|
67
67
|
| CommandRunner
|
|
68
68
|
| ToolCommands
|
|
69
|
+
|
|
70
|
+
export type SuccessExit = typeof EXIT_OK | typeof EXIT_STRAYS_FOUND
|
package/src/cli/Program.test.ts
CHANGED
|
@@ -60,7 +60,7 @@ const seed = (source: string, extra: Record<string, string> = {}): InMemRepo =>
|
|
|
60
60
|
const repo = new InMemRepo()
|
|
61
61
|
repo.writeFile("gtd.config.ts", source)
|
|
62
62
|
for (const [path, content] of Object.entries(extra)) repo.writeFile(path, content)
|
|
63
|
-
repo.
|
|
63
|
+
repo.seedCommit("chore: add custom workflow")
|
|
64
64
|
return repo
|
|
65
65
|
}
|
|
66
66
|
|
|
@@ -69,7 +69,7 @@ const seedBundled = (): InMemRepo => {
|
|
|
69
69
|
const repo = new InMemRepo()
|
|
70
70
|
repo.writeFile("README.md", "# test project\n")
|
|
71
71
|
repo.writeFile("gtd.config.ts", CONFIG_SCAFFOLD)
|
|
72
|
-
repo.
|
|
72
|
+
repo.seedCommit("chore: init")
|
|
73
73
|
return repo
|
|
74
74
|
}
|
|
75
75
|
|
|
@@ -199,7 +199,7 @@ describe("gtd --workflow <name> — the shipped workflows", () => {
|
|
|
199
199
|
const repo = seedBundled()
|
|
200
200
|
const base = repo.commitHistory().at(-1)!.hash
|
|
201
201
|
repo.writeFile("scratch.txt", "more work\n")
|
|
202
|
-
repo.
|
|
202
|
+
repo.seedCommit("chore: more work")
|
|
203
203
|
const before = repo.commitHistory().length
|
|
204
204
|
const { stdout, exitCode } = await run(
|
|
205
205
|
repo,
|
|
@@ -288,7 +288,7 @@ describe("gtd next --json — log path emission", () => {
|
|
|
288
288
|
const repo = new InMemRepo()
|
|
289
289
|
repo.writeFile("NOTE.md", "a note\n")
|
|
290
290
|
repo.writeFile("gtd.config.ts", CONFIG_SCAFFOLD)
|
|
291
|
-
repo.
|
|
291
|
+
repo.seedCommit("gtd: init")
|
|
292
292
|
return repo
|
|
293
293
|
}
|
|
294
294
|
|
|
@@ -703,7 +703,7 @@ describe("gtd next — refuses when history diverges from what replay reaches",
|
|
|
703
703
|
const diverged = async (): Promise<InMemRepo> => {
|
|
704
704
|
const repo = await atWorking()
|
|
705
705
|
repo.writeFile("WORK.md", "done\n")
|
|
706
|
-
repo.
|
|
706
|
+
repo.seedCommit("gtd(agent): elsewhere → review\n\nGtd-Step: elsewhere#1")
|
|
707
707
|
return repo
|
|
708
708
|
}
|
|
709
709
|
|
|
@@ -752,9 +752,7 @@ describe("a history written in a format this gtd does not read", () => {
|
|
|
752
752
|
const repo = seed(IDLE_THEN_WORKING)
|
|
753
753
|
await landTurn(repo, { "NOTE.md": "a note\n" })
|
|
754
754
|
repo.writeFile("WORK.md", "done\n")
|
|
755
|
-
repo.
|
|
756
|
-
"gtd(agent): elsewhere → review\n\nGtd-Step: elsewhere#1\nGtd-Format: 2",
|
|
757
|
-
)
|
|
755
|
+
repo.seedCommit("gtd(agent): elsewhere → review\n\nGtd-Step: elsewhere#1\nGtd-Format: 2")
|
|
758
756
|
return repo
|
|
759
757
|
}
|
|
760
758
|
|
|
@@ -779,7 +777,7 @@ describe("a history written in a format this gtd does not read", () => {
|
|
|
779
777
|
it("refuses even when a foreign subject hides the commit from the episode", async () => {
|
|
780
778
|
const repo = seed(IDLE_THEN_WORKING)
|
|
781
779
|
repo.writeFile("NOTE.md", "a note\n")
|
|
782
|
-
repo.
|
|
780
|
+
repo.seedCommit("gtd2[human] idle -> working\n\nGtd-Format: 2")
|
|
783
781
|
for (const command of ["next", "abandon"]) {
|
|
784
782
|
const { exitCode, stderr } = await run(repo, command)
|
|
785
783
|
expect(exitCode).toBe(1)
|
|
@@ -790,7 +788,7 @@ describe("a history written in a format this gtd does not read", () => {
|
|
|
790
788
|
it("a commit gtd never wrote is not read for a format, even quoting the trailer", async () => {
|
|
791
789
|
const repo = seed(IDLE_THEN_WORKING)
|
|
792
790
|
repo.writeFile("NOTE.md", "a note\n")
|
|
793
|
-
repo.
|
|
791
|
+
repo.seedCommit("feat: x\n\nGtd-Format: 2")
|
|
794
792
|
expect((await run(repo, "next")).exitCode).toBe(0)
|
|
795
793
|
})
|
|
796
794
|
|
|
@@ -798,7 +796,7 @@ describe("a history written in a format this gtd does not read", () => {
|
|
|
798
796
|
const repo = seed(IDLE_THEN_WORKING)
|
|
799
797
|
const base = repo.resolveRef("HEAD")!
|
|
800
798
|
repo.writeFile("NOTE.md", "a note\n")
|
|
801
|
-
repo.
|
|
799
|
+
repo.seedCommit("gtd(human): idle → working\n\nGtd-Step: idle#1\nGtd-Format: 2")
|
|
802
800
|
repo.updateRef(HISTORY_REF, repo.resolveRef("HEAD")!)
|
|
803
801
|
repo.hardResetTo(base)
|
|
804
802
|
const { exitCode, stderr } = await run(repo, "restore")
|
|
@@ -1695,6 +1693,7 @@ describe("runCommand — refuses in a repository with no commits", () => {
|
|
|
1695
1693
|
historyList: { kind: "historyList" },
|
|
1696
1694
|
historyShow: { kind: "historyShow", ref: "x" },
|
|
1697
1695
|
historyFetch: { kind: "historyFetch", remote: "origin" },
|
|
1696
|
+
strays: { kind: "strays", base: "HEAD", tip: "HEAD", globs: [] },
|
|
1698
1697
|
}
|
|
1699
1698
|
|
|
1700
1699
|
const stateKinds = (Object.keys(commandFor) as Command["kind"][]).filter(
|
|
@@ -1949,7 +1948,7 @@ describe("gtd next/land --json=<path> — the select branch", () => {
|
|
|
1949
1948
|
const repo = new InMemRepo()
|
|
1950
1949
|
repo.writeFile("NOTE.md", "a note\n")
|
|
1951
1950
|
repo.writeFile("gtd.config.ts", CONFIG_SCAFFOLD)
|
|
1952
|
-
repo.
|
|
1951
|
+
repo.seedCommit("gtd: init")
|
|
1953
1952
|
return repo
|
|
1954
1953
|
}
|
|
1955
1954
|
|
package/src/cli/Program.ts
CHANGED
|
@@ -42,6 +42,7 @@ import {
|
|
|
42
42
|
type NextMatch,
|
|
43
43
|
type StatusChange,
|
|
44
44
|
selectPath,
|
|
45
|
+
EXIT_OK,
|
|
45
46
|
} from "../wire/index.js"
|
|
46
47
|
import {
|
|
47
48
|
currentRest,
|
|
@@ -94,13 +95,16 @@ import {
|
|
|
94
95
|
type ArtifactOut,
|
|
95
96
|
type CommandRequirements,
|
|
96
97
|
type JsonMode,
|
|
98
|
+
type SuccessExit,
|
|
97
99
|
} from "./Output.js"
|
|
98
100
|
export {
|
|
99
101
|
SelectorUsageError,
|
|
100
102
|
type ArtifactOut,
|
|
101
103
|
type CommandRequirements,
|
|
102
104
|
type JsonMode,
|
|
105
|
+
type SuccessExit,
|
|
103
106
|
} from "./Output.js"
|
|
107
|
+
import { runStraysCommand } from "./Strays.js"
|
|
104
108
|
import { runHistoryFetchCommand, runHistoryListCommand, runHistoryShowCommand } from "./History.js"
|
|
105
109
|
|
|
106
110
|
/**
|
|
@@ -161,6 +165,13 @@ export type Command =
|
|
|
161
165
|
| { readonly kind: "historyList" }
|
|
162
166
|
| { readonly kind: "historyShow"; readonly ref: string }
|
|
163
167
|
| { readonly kind: "historyFetch"; readonly remote: string }
|
|
168
|
+
| {
|
|
169
|
+
readonly kind: "strays"
|
|
170
|
+
/** Each side of `<base>..<tip>`; an empty side is `HEAD`, as git reads it. */
|
|
171
|
+
readonly base: string
|
|
172
|
+
readonly tip: string
|
|
173
|
+
readonly globs: readonly string[]
|
|
174
|
+
}
|
|
164
175
|
| {
|
|
165
176
|
readonly kind: "judgeRun"
|
|
166
177
|
readonly provider?: "fixed" | "jev" | "llm"
|
|
@@ -1149,6 +1160,7 @@ export const needsOf = (kind: Command["kind"]): Needs => {
|
|
|
1149
1160
|
case "install":
|
|
1150
1161
|
case "loop":
|
|
1151
1162
|
case "judgeRun":
|
|
1163
|
+
case "strays":
|
|
1152
1164
|
return "none"
|
|
1153
1165
|
case "check":
|
|
1154
1166
|
case "uncheck":
|
|
@@ -1166,18 +1178,27 @@ export const standaloneKinds = (): readonly Command["kind"][] => [
|
|
|
1166
1178
|
"install",
|
|
1167
1179
|
"loop",
|
|
1168
1180
|
"judgeRun",
|
|
1181
|
+
"strays",
|
|
1169
1182
|
]
|
|
1170
1183
|
|
|
1171
1184
|
/**
|
|
1172
1185
|
* Dispatches to the named `run*Command` handler for every `Command.kind` —
|
|
1173
1186
|
* the counterpart of `Cli.ts`'s `parseArgv`, which has already validated
|
|
1174
|
-
* every field a handler receives.
|
|
1175
|
-
*
|
|
1176
|
-
* `runCli` supplies `EXIT_OK` once, after this Effect succeeds.
|
|
1187
|
+
* every field a handler receives. Only `strays` chooses its exit code; every
|
|
1188
|
+
* other branch returns `Effect<void>` and resolves to `EXIT_OK`.
|
|
1177
1189
|
*/
|
|
1190
|
+
const dispatchCommand = (
|
|
1191
|
+
command: Command,
|
|
1192
|
+
json: JsonMode,
|
|
1193
|
+
out: ArtifactOut,
|
|
1194
|
+
): Effect.Effect<SuccessExit, Error, CommandRequirements> => {
|
|
1195
|
+
if (command.kind === "strays") return runStraysCommand(command, json, out)
|
|
1196
|
+
return Effect.as(dispatchVoidCommand(command, json, out), EXIT_OK)
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1178
1199
|
// fallow-ignore-next-line complexity
|
|
1179
1200
|
const dispatchVoidCommand = (
|
|
1180
|
-
command: Command,
|
|
1201
|
+
command: Exclude<Command, { kind: "strays" }>,
|
|
1181
1202
|
json: JsonMode,
|
|
1182
1203
|
out: ArtifactOut,
|
|
1183
1204
|
): Effect.Effect<void, Error, CommandRequirements> => {
|
|
@@ -1255,8 +1276,8 @@ export const runCommand = (
|
|
|
1255
1276
|
command: Command,
|
|
1256
1277
|
json: JsonMode,
|
|
1257
1278
|
out: ArtifactOut,
|
|
1258
|
-
): Effect.Effect<
|
|
1259
|
-
const dispatch =
|
|
1279
|
+
): Effect.Effect<SuccessExit, Error, CommandRequirements> => {
|
|
1280
|
+
const dispatch = dispatchCommand(command, json, out)
|
|
1260
1281
|
if (needsOf(command.kind) !== "state") return dispatch
|
|
1261
1282
|
return Effect.gen(function* () {
|
|
1262
1283
|
const git = yield* GitService
|
|
@@ -1278,6 +1299,6 @@ export const runCommand = (
|
|
|
1278
1299
|
for (const warning of config.warnings) {
|
|
1279
1300
|
yield* narrator.warn(`gtd: warning: ${formatDiagnostic(warning)}`)
|
|
1280
1301
|
}
|
|
1281
|
-
yield* dispatch
|
|
1302
|
+
return yield* dispatch
|
|
1282
1303
|
})
|
|
1283
1304
|
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { Effect } from "effect"
|
|
2
|
+
import { describe, expect, it } from "vitest"
|
|
3
|
+
import { EXIT_OK, EXIT_RUNTIME_ERROR, EXIT_STRAYS_FOUND } from "../wire/index.js"
|
|
4
|
+
import { InMemRepo, makeCapturingCliIo } from "../testkit/index.js"
|
|
5
|
+
import { runCli } from "./index.js"
|
|
6
|
+
|
|
7
|
+
const seed = (): InMemRepo => {
|
|
8
|
+
const repo = new InMemRepo()
|
|
9
|
+
repo.writeFile("README.md", "x\n")
|
|
10
|
+
repo.commitAllWithPrefix("base")
|
|
11
|
+
repo.updateRef("base", "HEAD")
|
|
12
|
+
repo.writeFile("src/a.ts", "a\n")
|
|
13
|
+
repo.writeFile("notes/b.md", "b\n")
|
|
14
|
+
repo.writeFile("notes/c.md", "c\n")
|
|
15
|
+
repo.commitAllWithPrefix("work")
|
|
16
|
+
return repo
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const strays = async (repo: InMemRepo, ...args: string[]) => {
|
|
20
|
+
const { io, result } = makeCapturingCliIo(repo)
|
|
21
|
+
await Effect.runPromise(runCli(["node", "gtd.js", "strays", ...args], io))
|
|
22
|
+
return result()
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe("gtd strays — stderr commentary", () => {
|
|
26
|
+
it("quiet: only the N of M summary", async () => {
|
|
27
|
+
const { stderr, exitCode } = await strays(seed(), "base..HEAD", "src/**")
|
|
28
|
+
expect(stderr).toBe("gtd strays: 2 of 3 touched files outside the allowlist\n")
|
|
29
|
+
expect(exitCode).toBe(EXIT_STRAYS_FOUND)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it("--verbose: one stray: line per path, then the summary", async () => {
|
|
33
|
+
const { stderr } = await strays(seed(), "base..HEAD", "src/**", "--verbose")
|
|
34
|
+
expect(stderr).toBe(
|
|
35
|
+
'stray: "notes/b.md"\nstray: "notes/c.md"\ngtd strays: 2 of 3 touched files outside the allowlist\n',
|
|
36
|
+
)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it("a path with a line break and an escape reaches stderr JSON-quoted", async () => {
|
|
40
|
+
const repo = seed()
|
|
41
|
+
repo.writeFile("x\n\x1b[2J", "x\n")
|
|
42
|
+
repo.commitAllWithPrefix("odd")
|
|
43
|
+
const { stderr } = await strays(repo, "base..HEAD", "src/**", "--verbose")
|
|
44
|
+
expect(stderr).toContain('stray: "x\\n\\u001b[2J"\n')
|
|
45
|
+
expect(stderr).not.toContain("\x1b")
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it("clean run: the single all-inside line", async () => {
|
|
49
|
+
const { stderr, exitCode } = await strays(seed(), "base..HEAD", "src/**", "notes/*")
|
|
50
|
+
expect(stderr).toBe("gtd strays: no strays — 3 touched files, all inside the allowlist\n")
|
|
51
|
+
expect(exitCode).toBe(EXIT_OK)
|
|
52
|
+
})
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
describe("gtd strays --json=<index>", () => {
|
|
56
|
+
it("--json=0 prints the first stray, exit 10", async () => {
|
|
57
|
+
const { stdout, exitCode } = await strays(seed(), "base..HEAD", "src/**", "--json=0")
|
|
58
|
+
expect(stdout).toBe("notes/b.md\n")
|
|
59
|
+
expect(exitCode).toBe(EXIT_STRAYS_FOUND)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it("--json=0 on a clean range prints nothing, exit 0", async () => {
|
|
63
|
+
const { stdout, exitCode } = await strays(seed(), "base..HEAD", "src/**", "notes/*", "--json=0")
|
|
64
|
+
expect(stdout).toBe("")
|
|
65
|
+
expect(exitCode).toBe(EXIT_OK)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it("an index past the end prints nothing and keeps exit 10", async () => {
|
|
69
|
+
const { stdout, exitCode } = await strays(seed(), "base..HEAD", "src/**", "--json=5")
|
|
70
|
+
expect(stdout).toBe("")
|
|
71
|
+
expect(exitCode).toBe(EXIT_STRAYS_FOUND)
|
|
72
|
+
})
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
describe("gtd strays — unknown revisions", () => {
|
|
76
|
+
it.each([
|
|
77
|
+
["nope..HEAD", "nope"],
|
|
78
|
+
["HEAD..nope", "nope"],
|
|
79
|
+
["HEAD..--output=x", "--output=x"],
|
|
80
|
+
])("%s: exit 1, empty stdout, no file written", async (range, side) => {
|
|
81
|
+
const repo = seed()
|
|
82
|
+
const { stdout, stderr, exitCode } = await strays(repo, range)
|
|
83
|
+
expect(stdout).toBe("")
|
|
84
|
+
expect(stderr).toContain(`unknown revision '${side}'`)
|
|
85
|
+
expect(exitCode).toBe(EXIT_RUNTIME_ERROR)
|
|
86
|
+
expect(repo.readFile("x")).toBeUndefined()
|
|
87
|
+
})
|
|
88
|
+
})
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { Effect } from "effect"
|
|
2
|
+
import { GitService, GtdError, Narrator, type GitOperations } from "../platform/index.js"
|
|
3
|
+
import { strayPaths } from "../step/index.js"
|
|
4
|
+
import { EXIT_OK, EXIT_STRAYS_FOUND } from "../wire/index.js"
|
|
5
|
+
import {
|
|
6
|
+
SelectorUsageError,
|
|
7
|
+
writeSelection,
|
|
8
|
+
type ArtifactOut,
|
|
9
|
+
type JsonMode,
|
|
10
|
+
type SuccessExit,
|
|
11
|
+
} from "./Output.js"
|
|
12
|
+
|
|
13
|
+
// `^{commit}` peels to a commit: a tree or blob would make `git diff` print paths not anchored at the repo root.
|
|
14
|
+
const resolveSide = (git: GitOperations, side: string): Effect.Effect<string, Error> =>
|
|
15
|
+
git
|
|
16
|
+
.resolveRef(`${side}^{commit}`)
|
|
17
|
+
.pipe(Effect.mapError(() => new GtdError(`gtd strays: unknown revision '${side}'`)))
|
|
18
|
+
|
|
19
|
+
/** stdout carries the array alone; the summary is ungated stderr so a quiet run still explains its exit code, the per-path lines are narration. */
|
|
20
|
+
export const runStraysCommand = (
|
|
21
|
+
command: {
|
|
22
|
+
readonly base: string
|
|
23
|
+
readonly tip: string
|
|
24
|
+
readonly globs: readonly string[]
|
|
25
|
+
},
|
|
26
|
+
json: JsonMode,
|
|
27
|
+
out: ArtifactOut,
|
|
28
|
+
): Effect.Effect<SuccessExit, Error, GitService | Narrator> =>
|
|
29
|
+
Effect.gen(function* () {
|
|
30
|
+
const git = yield* GitService
|
|
31
|
+
const narrator = yield* Narrator
|
|
32
|
+
const base = yield* resolveSide(git, command.base)
|
|
33
|
+
const tip = yield* resolveSide(git, command.tip)
|
|
34
|
+
const touched = yield* git.diffPaths(base, tip)
|
|
35
|
+
const strays = strayPaths(touched, command.globs)
|
|
36
|
+
const total = touched.length
|
|
37
|
+
|
|
38
|
+
if (json.kind === "select") {
|
|
39
|
+
// An index past the end is absent here, not a usage error: it must not mask the exit 0/10 verdict.
|
|
40
|
+
yield* writeSelection(out, strays, json.path).pipe(
|
|
41
|
+
Effect.catchIf(
|
|
42
|
+
(error) => error instanceof SelectorUsageError && /^\d+$/.test(json.path),
|
|
43
|
+
() => Effect.void,
|
|
44
|
+
),
|
|
45
|
+
)
|
|
46
|
+
} else out.write(`${JSON.stringify(strays)}\n`)
|
|
47
|
+
|
|
48
|
+
// JSON-quoted: a path is author-controlled and must not carry terminal escapes.
|
|
49
|
+
for (const path of strays) yield* narrator.narrate(`stray: ${JSON.stringify(path)}`)
|
|
50
|
+
yield* narrator.warn(
|
|
51
|
+
strays.length === 0
|
|
52
|
+
? `gtd strays: no strays — ${total} touched files, all inside the allowlist`
|
|
53
|
+
: `gtd strays: ${strays.length} of ${total} touched files outside the allowlist`,
|
|
54
|
+
)
|
|
55
|
+
return strays.length === 0 ? EXIT_OK : EXIT_STRAYS_FOUND
|
|
56
|
+
})
|