@pmelab/gtd 64.1.2 → 64.3.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 +28 -16
- package/dist/chunks/{steering-CQzL-lX1.mjs → steering-DEh_MS40.mjs} +57 -0
- package/dist/chunks/{testkit-D539mnDI.mjs → testkit-BUX-47TD.mjs} +61 -18
- package/dist/gtd.bundle.mjs +144 -51
- package/dist/gtd.core.mjs +110 -109
- package/dist/steering.mjs +1 -1
- package/dist/testkit/gherkin.mjs +1 -1
- package/dist/testkit.mjs +1 -1
- package/dist/web/main.js +81 -14
- package/dist/workflow-kit.mjs +1 -1
- package/package.json +1 -1
- package/src/cli/Cli.test.ts +1 -0
- package/src/cli/Cli.ts +17 -2
- package/src/cli/Program.ts +6 -0
- package/src/cli/ToolCommands.ts +1 -0
- package/src/edge/Edge.test.ts +22 -0
- package/src/edge/Edge.ts +22 -0
- package/src/edge/index.ts +1 -0
- package/src/platform/Git.ts +33 -10
- package/src/steering/SteeringFormat.ts +4 -1
- package/src/steering/freeform.test.ts +51 -1
- package/src/steering/freeform.ts +38 -0
- package/src/twins/FakeGitOperations.ts +2 -0
- package/src/twins/GitTiers.ts +26 -1
- package/src/twins/InMemRepo.ts +5 -0
package/dist/steering.mjs
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export { g as ANSWER_FIX, _ as ANSWER_LEAD, v as ANSWER_WONT_FIX, O as FIX_BOX, E as FREE_TEXT_PLACEHOLDER, k as NOISE_HEADING, y as REVIEW_SECTIONS, h as applySteeringEdits, A as findingEntries, I as formatFinding, m as freeFormFormat, D as isAnswered, f as listFormat, r as openQuestions, j as openThreadFindings, c as openThreads, t as parseCodeThreads, M as parseFootnotes, N as parseThreads, P as parseThreadsWithFindings, b as reviewChunks, x as reviewDecisions, S as reviewNotes, C as reviewRisks, w as reviewSections, i as sectionBodies, p as sectionFormat, a as sections, l as steeringFormatFor, n as stripCodeThreads, o as threads, T as unansweredNotes } from "./chunks/steering-
|
|
1
|
+
export { g as ANSWER_FIX, _ as ANSWER_LEAD, v as ANSWER_WONT_FIX, O as FIX_BOX, E as FREE_TEXT_PLACEHOLDER, k as NOISE_HEADING, y as REVIEW_SECTIONS, h as applySteeringEdits, A as findingEntries, I as formatFinding, m as freeFormFormat, D as isAnswered, f as listFormat, r as openQuestions, j as openThreadFindings, c as openThreads, t as parseCodeThreads, M as parseFootnotes, N as parseThreads, P as parseThreadsWithFindings, b as reviewChunks, x as reviewDecisions, S as reviewNotes, C as reviewRisks, w as reviewSections, i as sectionBodies, p as sectionFormat, a as sections, l as steeringFormatFor, n as stripCodeThreads, o as threads, T as unansweredNotes } from "./chunks/steering-DEh_MS40.mjs";
|
package/dist/testkit/gherkin.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { n as runsItsScript, r as testRepo } from "../chunks/testkit-
|
|
1
|
+
import { n as runsItsScript, r as testRepo } from "../chunks/testkit-BUX-47TD.mjs";
|
|
2
2
|
import { pathToFileURL } from "node:url";
|
|
3
3
|
import { resolve } from "node:path";
|
|
4
4
|
import { After, AfterAll, Given, QuickPickleWorld, Then, When, setWorldConstructor } from "quickpickle";
|
package/dist/testkit.mjs
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
import { H as installContext } from "./chunks/flows-dnIE_qbe.mjs";
|
|
2
|
-
import { a as verdictWhen, i as verdict, n as runsItsScript, o as whenPrompted, r as testRepo, t as inTurn } from "./chunks/testkit-
|
|
2
|
+
import { a as verdictWhen, i as verdict, n as runsItsScript, o as whenPrompted, r as testRepo, t as inTurn } from "./chunks/testkit-BUX-47TD.mjs";
|
|
3
3
|
export { inTurn, installContext, runsItsScript, testRepo, verdict, verdictWhen, whenPrompted };
|
package/dist/web/main.js
CHANGED
|
@@ -33541,16 +33541,43 @@ const headingTagFor = (depth) => {
|
|
|
33541
33541
|
if (depth === 3) return "h3";
|
|
33542
33542
|
return "h4";
|
|
33543
33543
|
};
|
|
33544
|
+
const TickContext = (0, import_react.createContext)(void 0);
|
|
33545
|
+
/** A task item's box: tappable when tick wiring exists, else read-only. `picked` is the group's optimistic pick. */
|
|
33546
|
+
const TaskBox = ({ item, groupLine, picked }) => {
|
|
33547
|
+
const ticks = (0, import_react.useContext)(TickContext);
|
|
33548
|
+
const on = picked === void 0 ? item.checked === true : item.line === picked;
|
|
33549
|
+
const line = item.line;
|
|
33550
|
+
const tappable = ticks !== void 0 && groupLine !== void 0 && line !== void 0;
|
|
33551
|
+
return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("input", {
|
|
33552
|
+
type: "checkbox",
|
|
33553
|
+
checked: on,
|
|
33554
|
+
readOnly: !tappable,
|
|
33555
|
+
onChange: () => {
|
|
33556
|
+
if (tappable && !on) ticks.onTick(groupLine, line);
|
|
33557
|
+
},
|
|
33558
|
+
className: "mr-2 align-middle"
|
|
33559
|
+
});
|
|
33560
|
+
};
|
|
33561
|
+
/** A list's pick-one group: its first task item's line, and the optimistic pick there. */
|
|
33562
|
+
const useGroupPick = (items) => {
|
|
33563
|
+
const ticks = (0, import_react.useContext)(TickContext);
|
|
33564
|
+
const groupLine = items.find((i) => i.checked !== void 0 && i.line !== void 0)?.line;
|
|
33565
|
+
return {
|
|
33566
|
+
groupLine,
|
|
33567
|
+
picked: groupLine === void 0 ? void 0 : ticks?.pick(groupLine)
|
|
33568
|
+
};
|
|
33569
|
+
};
|
|
33544
33570
|
/** One `list` block's own items, recursively — a nested `items` array renders as a nested `ul`/`ol` inside its parent's `li`. */
|
|
33545
33571
|
const BlockList = ({ items, ordered }) => {
|
|
33546
|
-
|
|
33572
|
+
const ListTag = ordered ? "ol" : "ul";
|
|
33573
|
+
const { groupLine, picked } = useGroupPick(items);
|
|
33574
|
+
return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(ListTag, {
|
|
33547
33575
|
className: `m-0 list-outside pl-5 ${ordered ? "list-decimal" : "list-disc"}`,
|
|
33548
33576
|
children: items.map((item, index) => /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("li", { children: [
|
|
33549
|
-
item.checked !== void 0 && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
33550
|
-
|
|
33551
|
-
|
|
33552
|
-
|
|
33553
|
-
className: "mr-2 align-middle"
|
|
33577
|
+
item.checked !== void 0 && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(TaskBox, {
|
|
33578
|
+
item,
|
|
33579
|
+
groupLine,
|
|
33580
|
+
picked
|
|
33554
33581
|
}),
|
|
33555
33582
|
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(Inline, {
|
|
33556
33583
|
inline: item.inline,
|
|
@@ -34021,7 +34048,7 @@ const FreeFormDoneRow = ({ onDone, busy }) => /* @__PURE__ */ (0, import_jsx_run
|
|
|
34021
34048
|
children: "Done"
|
|
34022
34049
|
})
|
|
34023
34050
|
});
|
|
34024
|
-
const FreeFormView = ({ view, filePath, isLoading, readError, mode, onSave, onDone, onSaveNote, busy }) => {
|
|
34051
|
+
const FreeFormView = ({ view, filePath, isLoading, readError, mode, onSave, onDone, onTick, onSaveNote, busy }) => {
|
|
34025
34052
|
const [noteSheetAnchor, setNoteSheetAnchor] = (0, import_react.useState)(void 0);
|
|
34026
34053
|
const overlay = useWriteStore((s) => s.overlay);
|
|
34027
34054
|
const save = useWriteStore((s) => s.save);
|
|
@@ -34052,6 +34079,25 @@ const FreeFormView = ({ view, filePath, isLoading, readError, mode, onSave, onDo
|
|
|
34052
34079
|
write: onSave === void 0 ? void 0 : onSave(APPEND_LINE, text)
|
|
34053
34080
|
}).catch(() => {});
|
|
34054
34081
|
};
|
|
34082
|
+
const tickWiring = {
|
|
34083
|
+
pick: (groupLine) => {
|
|
34084
|
+
const v = overlay[cellKey(filePath, {
|
|
34085
|
+
kind: "paragraph",
|
|
34086
|
+
line: groupLine
|
|
34087
|
+
}, "tick")]?.value;
|
|
34088
|
+
return typeof v === "number" ? v : void 0;
|
|
34089
|
+
},
|
|
34090
|
+
onTick: (groupLine, line) => {
|
|
34091
|
+
save({
|
|
34092
|
+
cell: cellKey(filePath, {
|
|
34093
|
+
kind: "paragraph",
|
|
34094
|
+
line: groupLine
|
|
34095
|
+
}, "tick"),
|
|
34096
|
+
optimistic: line,
|
|
34097
|
+
write: onTick === void 0 ? void 0 : onTick(line)
|
|
34098
|
+
}).catch(() => {});
|
|
34099
|
+
}
|
|
34100
|
+
};
|
|
34055
34101
|
const runDone = () => {
|
|
34056
34102
|
if (onDone === void 0) return;
|
|
34057
34103
|
save({
|
|
@@ -34071,10 +34117,13 @@ const FreeFormView = ({ view, filePath, isLoading, readError, mode, onSave, onDo
|
|
|
34071
34117
|
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", {
|
|
34072
34118
|
"data-testid": "freeform-scroll",
|
|
34073
34119
|
className: "min-h-0 flex-1 overflow-auto",
|
|
34074
|
-
children: view.nodes.length === 0 ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(EmptyDocumentCapture, { onSave: saveEmpty }) : /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
34075
|
-
|
|
34076
|
-
|
|
34077
|
-
|
|
34120
|
+
children: view.nodes.length === 0 ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(EmptyDocumentCapture, { onSave: saveEmpty }) : /* @__PURE__ */ (0, import_jsx_runtime.jsx)(TickContext.Provider, {
|
|
34121
|
+
value: onTick === void 0 ? void 0 : tickWiring,
|
|
34122
|
+
children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(ProseBlocks, {
|
|
34123
|
+
nodes: view.nodes,
|
|
34124
|
+
noteOverrides,
|
|
34125
|
+
onOpenNote: (n) => setNoteSheetAnchor(n.anchor)
|
|
34126
|
+
})
|
|
34078
34127
|
})
|
|
34079
34128
|
}),
|
|
34080
34129
|
onDone !== void 0 && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(FreeFormDoneRow, {
|
|
@@ -34125,6 +34174,22 @@ const useFreeFormMutations = (filePath, mode, onWriteSuccess) => {
|
|
|
34125
34174
|
return result;
|
|
34126
34175
|
});
|
|
34127
34176
|
};
|
|
34177
|
+
const onTick = (line) => (cas) => {
|
|
34178
|
+
const anchor = {
|
|
34179
|
+
kind: "paragraph",
|
|
34180
|
+
line
|
|
34181
|
+
};
|
|
34182
|
+
return setValue.mutateAsync({
|
|
34183
|
+
filePath,
|
|
34184
|
+
...cas,
|
|
34185
|
+
mode,
|
|
34186
|
+
anchor,
|
|
34187
|
+
checked: true
|
|
34188
|
+
}).then((result) => {
|
|
34189
|
+
onWriteSuccess(result.formatNotice);
|
|
34190
|
+
return result;
|
|
34191
|
+
});
|
|
34192
|
+
};
|
|
34128
34193
|
const onSaveNote = (anchor, text) => (cas) => writeNote.mutateAsync({
|
|
34129
34194
|
filePath,
|
|
34130
34195
|
...cas,
|
|
@@ -34138,6 +34203,7 @@ const useFreeFormMutations = (filePath, mode, onWriteSuccess) => {
|
|
|
34138
34203
|
const onDone = () => done.mutateAsync({});
|
|
34139
34204
|
return {
|
|
34140
34205
|
onSave,
|
|
34206
|
+
onTick,
|
|
34141
34207
|
onSaveNote,
|
|
34142
34208
|
onDone,
|
|
34143
34209
|
isDone: done.isSuccess
|
|
@@ -34148,7 +34214,7 @@ const FreeFormInner = ({ filePath, mode, data, isLoading, error }) => {
|
|
|
34148
34214
|
const indicatorProps = useSaveIndicatorProps();
|
|
34149
34215
|
const pending = useWriteStore((s) => s.pendingCount);
|
|
34150
34216
|
const [formatNotice, setFormatNotice] = (0, import_react.useState)(void 0);
|
|
34151
|
-
const { onSave, onSaveNote, onDone, isDone } = useFreeFormMutations(filePath, mode, setFormatNotice);
|
|
34217
|
+
const { onSave, onTick, onSaveNote, onDone, isDone } = useFreeFormMutations(filePath, mode, setFormatNotice);
|
|
34152
34218
|
return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_jsx_runtime.Fragment, { children: [
|
|
34153
34219
|
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(SaveIndicator, { ...indicatorProps }),
|
|
34154
34220
|
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(FormatNoticeBanner, {
|
|
@@ -34163,6 +34229,7 @@ const FreeFormInner = ({ filePath, mode, data, isLoading, error }) => {
|
|
|
34163
34229
|
mode,
|
|
34164
34230
|
onSave,
|
|
34165
34231
|
onDone,
|
|
34232
|
+
onTick,
|
|
34166
34233
|
onSaveNote,
|
|
34167
34234
|
busy: pending > 0
|
|
34168
34235
|
})
|
|
@@ -35882,8 +35949,8 @@ const openable = (step) => step?.status === "ok" && step.file !== void 0;
|
|
|
35882
35949
|
* `"qa"` means `Plan`; `"findings"` means `Findings`; anything else (absent, or an unregistered mode name)
|
|
35883
35950
|
* falls through to `FreeForm`, which reads the file as plain markdown and
|
|
35884
35951
|
* offers only a note per block, plus a textfield when it's empty. Neither
|
|
35885
|
-
* screen offers a way back to a list — `done`
|
|
35886
|
-
*
|
|
35952
|
+
* screen offers a way back to a list — `done` hands the turn back (the
|
|
35953
|
+
* server keeps running until the outer loop moves on).
|
|
35887
35954
|
*
|
|
35888
35955
|
* Never returns `null`: a blank white screen was package 03's own regression
|
|
35889
35956
|
* (a phone over a tailnet with nothing on-screen to explain why) — every
|
package/dist/workflow-kit.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { B as head, F as changesSince, G as mark, P as changes, X as read, Z as refuse, d as hasThreadFor, et as scope, g as requireReplies, v as requireThreadsClosed } from "./chunks/flows-dnIE_qbe.mjs";
|
|
2
|
-
import { C as reviewRisks, N as parseThreads, S as reviewNotes, T as unansweredNotes, n as stripCodeThreads, x as reviewDecisions } from "./chunks/steering-
|
|
2
|
+
import { C as reviewRisks, N as parseThreads, S as reviewNotes, T as unansweredNotes, n as stripCodeThreads, x as reviewDecisions } from "./chunks/steering-DEh_MS40.mjs";
|
|
3
3
|
// src/workflow-kit/health.ts
|
|
4
4
|
/** Hand a red suite that repeated attempts could not fix to a person, until `limit` rounds are spent. */
|
|
5
5
|
const escalation = async (count, steps) => {
|
package/package.json
CHANGED
package/src/cli/Cli.test.ts
CHANGED
package/src/cli/Cli.ts
CHANGED
|
@@ -184,7 +184,7 @@ const FLAGS: readonly FlagRow[] = [
|
|
|
184
184
|
},
|
|
185
185
|
scopeError: "gtd: --port is only valid for `gtd ui`",
|
|
186
186
|
valueHint: "<n>",
|
|
187
|
-
help: "The tailscale serve port (default: the first free of 8443, 10000, 443), or with --host the port to bind (default: any free one)",
|
|
187
|
+
help: "The tailscale serve port (default: the first free of 8443, 10000, 443), or with --host or --http the port to bind (default: any free one)",
|
|
188
188
|
},
|
|
189
189
|
{
|
|
190
190
|
name: "--host",
|
|
@@ -209,6 +209,16 @@ const FLAGS: readonly FlagRow[] = [
|
|
|
209
209
|
valueHint: "",
|
|
210
210
|
help: "Bind directly with a throwaway self-signed certificate instead of the configured ui.cert/ui.key",
|
|
211
211
|
},
|
|
212
|
+
{
|
|
213
|
+
name: "--http",
|
|
214
|
+
arity: 0,
|
|
215
|
+
repeatable: false,
|
|
216
|
+
scope: (kind) => kind === "ui",
|
|
217
|
+
decode: () => Result.succeed(true),
|
|
218
|
+
scopeError: "gtd: --http is only valid for `gtd ui`",
|
|
219
|
+
valueHint: "",
|
|
220
|
+
help: "Serve plain HTTP for a local TLS-terminating proxy; binds 127.0.0.1 unless a loopback --host is given",
|
|
221
|
+
},
|
|
212
222
|
{
|
|
213
223
|
name: "--dev",
|
|
214
224
|
arity: 0,
|
|
@@ -547,7 +557,7 @@ Prints a sorted JSON array ([] when clean) and a summary on stderr; --verbose ad
|
|
|
547
557
|
arity: "none",
|
|
548
558
|
group: "Inspect",
|
|
549
559
|
summary: "Serve the web and phone client",
|
|
550
|
-
details: `Serve gtd's web and phone client for this worktree. By default it publishes through \`tailscale serve\`, reachable from anywhere on the tailnet, on the first of ports 8443, 10000 and 443 that works; when none does it serves HTTPS locally instead of refusing.`,
|
|
560
|
+
details: `Serve gtd's web and phone client for this worktree. By default it publishes through \`tailscale serve\`, reachable from anywhere on the tailnet, on the first of ports 8443, 10000 and 443 that works; when none does it serves HTTPS locally instead of refusing. Done saves the steering file and keeps serving; the server exits when the worktree moves on to another step, or on a signal.`,
|
|
551
561
|
},
|
|
552
562
|
{
|
|
553
563
|
token: "guide",
|
|
@@ -1137,6 +1147,7 @@ export const parseArgv = (argv: readonly string[]): CliPlan => {
|
|
|
1137
1147
|
readonly "--spec"?: string
|
|
1138
1148
|
readonly "--host"?: string
|
|
1139
1149
|
readonly "--self-signed"?: boolean
|
|
1150
|
+
readonly "--http"?: boolean
|
|
1140
1151
|
readonly "--dev"?: boolean
|
|
1141
1152
|
readonly "--provider"?: "fixed" | "jev" | "llm"
|
|
1142
1153
|
readonly "--answers"?: string
|
|
@@ -1185,6 +1196,9 @@ export const parseArgv = (argv: readonly string[]): CliPlan => {
|
|
|
1185
1196
|
}
|
|
1186
1197
|
|
|
1187
1198
|
if (kind === "ui") {
|
|
1199
|
+
if (bag["--http"] === true && bag["--self-signed"] === true) {
|
|
1200
|
+
return usagePlan("gtd: --http and --self-signed cannot be combined", jsonSeen)
|
|
1201
|
+
}
|
|
1188
1202
|
return {
|
|
1189
1203
|
kind: "command",
|
|
1190
1204
|
command: {
|
|
@@ -1192,6 +1206,7 @@ export const parseArgv = (argv: readonly string[]): CliPlan => {
|
|
|
1192
1206
|
...(bag["--host"] !== undefined ? { host: bag["--host"] } : {}),
|
|
1193
1207
|
...(bag["--port"] !== undefined ? { port: bag["--port"] } : {}),
|
|
1194
1208
|
selfSigned: bag["--self-signed"] ?? false,
|
|
1209
|
+
...(bag["--http"] === true ? { http: true } : {}),
|
|
1195
1210
|
dev: bag["--dev"] ?? false,
|
|
1196
1211
|
},
|
|
1197
1212
|
json,
|
package/src/cli/Program.ts
CHANGED
|
@@ -51,6 +51,7 @@ import {
|
|
|
51
51
|
} from "../wire/index.js"
|
|
52
52
|
import {
|
|
53
53
|
currentRest,
|
|
54
|
+
refuseHugeUntracked,
|
|
54
55
|
repinAnchor,
|
|
55
56
|
startRefusal,
|
|
56
57
|
currentRun,
|
|
@@ -144,6 +145,7 @@ export type Command =
|
|
|
144
145
|
readonly host?: string
|
|
145
146
|
readonly port?: number
|
|
146
147
|
readonly selfSigned: boolean
|
|
148
|
+
readonly http?: boolean
|
|
147
149
|
readonly dev: boolean
|
|
148
150
|
}
|
|
149
151
|
| {
|
|
@@ -310,6 +312,7 @@ const planLanding = (
|
|
|
310
312
|
current?: Rest,
|
|
311
313
|
): Effect.Effect<LandResult, Error, CommandRequirements> =>
|
|
312
314
|
Effect.gen(function* () {
|
|
315
|
+
yield* refuseHugeUntracked
|
|
313
316
|
const rest = current ?? (yield* currentRest)
|
|
314
317
|
const snapshot = yield* snapshotFromRest(rest, opts.judge)
|
|
315
318
|
const outcome = planStepPure(snapshot, opts)
|
|
@@ -596,6 +599,7 @@ const startWorkflow = (
|
|
|
596
599
|
new GtdUsageError(`${commandLabel}: unknown workflow "${workflow}" — ${startable}`),
|
|
597
600
|
)
|
|
598
601
|
}
|
|
602
|
+
yield* refuseHugeUntracked
|
|
599
603
|
const rest = yield* restAt(undefined, varOverrides)
|
|
600
604
|
const started = yield* startRefusal(rest, resolved, varOverrides)
|
|
601
605
|
const plan = yield* planStart(
|
|
@@ -967,6 +971,7 @@ const runNextCommand = (
|
|
|
967
971
|
out: ArtifactOut,
|
|
968
972
|
): Effect.Effect<void, Error, CommandRequirements> =>
|
|
969
973
|
Effect.gen(function* () {
|
|
974
|
+
yield* refuseHugeUntracked
|
|
970
975
|
const rest = yield* currentRest
|
|
971
976
|
const rendered = yield* renderRest(rest)
|
|
972
977
|
const fields = yield* gatherBeatDocument(rest, rendered)
|
|
@@ -1260,6 +1265,7 @@ const runUiCliCommand = (
|
|
|
1260
1265
|
...(command.host !== undefined ? { host: command.host } : {}),
|
|
1261
1266
|
...(command.port !== undefined ? { port: command.port } : {}),
|
|
1262
1267
|
selfSigned: command.selfSigned,
|
|
1268
|
+
...(command.http === true ? { http: true } : {}),
|
|
1263
1269
|
dev: command.dev,
|
|
1264
1270
|
},
|
|
1265
1271
|
config: config.ui,
|
package/src/cli/ToolCommands.ts
CHANGED
package/src/edge/Edge.test.ts
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
currentRun,
|
|
7
7
|
historyTrail,
|
|
8
8
|
noProcessUnderway,
|
|
9
|
+
refuseHugeUntracked,
|
|
9
10
|
renderRest,
|
|
10
11
|
restAt,
|
|
11
12
|
restIsIdle,
|
|
@@ -768,6 +769,27 @@ describe("restAt", () => {
|
|
|
768
769
|
])
|
|
769
770
|
})
|
|
770
771
|
|
|
772
|
+
it("refuses over 1000 untracked files and proceeds at exactly 1000", async () => {
|
|
773
|
+
const repo = repoWith(LINEAR)
|
|
774
|
+
const files = (n: number) =>
|
|
775
|
+
Object.fromEntries(Array.from({ length: n }, (_, i) => [`big/f${i}.txt`, "x\n"]))
|
|
776
|
+
write(repo, files(1000))
|
|
777
|
+
await provide(refuseHugeUntracked, repo)
|
|
778
|
+
write(repo, files(1001))
|
|
779
|
+
await expect(provide(refuseHugeUntracked, repo)).rejects.toThrow(
|
|
780
|
+
/1001 untracked files.*big \(1001\).*\.gitignore/,
|
|
781
|
+
)
|
|
782
|
+
})
|
|
783
|
+
|
|
784
|
+
it("resolving the current rest never refuses a huge untracked tree", async () => {
|
|
785
|
+
const repo = repoWith(LINEAR)
|
|
786
|
+
write(
|
|
787
|
+
repo,
|
|
788
|
+
Object.fromEntries(Array.from({ length: 1001 }, (_, i) => [`big/f${i}.txt`, "x\n"])),
|
|
789
|
+
)
|
|
790
|
+
expect((await provide(currentRest, repo)).changes).toHaveLength(1001)
|
|
791
|
+
})
|
|
792
|
+
|
|
771
793
|
it("restAt(ref) resolves where the process rested at that commit", async () => {
|
|
772
794
|
const repo = repoWith(LINEAR)
|
|
773
795
|
const boundary = headOf(repo)
|
package/src/edge/Edge.ts
CHANGED
|
@@ -971,6 +971,28 @@ const setupFor = (
|
|
|
971
971
|
return { resolved, def, vars, env, setup }
|
|
972
972
|
})
|
|
973
973
|
|
|
974
|
+
const UNTRACKED_LIMIT = 1000
|
|
975
|
+
|
|
976
|
+
// `git add -A` would commit every untracked file, so a huge tree is refused —
|
|
977
|
+
// only by the commands that land (next, land, judge answer, start). Read-only
|
|
978
|
+
// rest readers never refuse.
|
|
979
|
+
export const refuseHugeUntracked = Effect.gen(function* () {
|
|
980
|
+
const paths = yield* (yield* GitService).untrackedPaths()
|
|
981
|
+
if (paths.length <= UNTRACKED_LIMIT) return
|
|
982
|
+
const dirs = new Map<string, number>()
|
|
983
|
+
for (const p of paths) {
|
|
984
|
+
const i = p.lastIndexOf("/")
|
|
985
|
+
const d = i === -1 ? "." : p.slice(0, i)
|
|
986
|
+
dirs.set(d, (dirs.get(d) ?? 0) + 1)
|
|
987
|
+
}
|
|
988
|
+
const top = [...dirs].sort((a, b) => b[1] - a[1]).slice(0, 3)
|
|
989
|
+
return yield* Effect.fail(
|
|
990
|
+
new Error(
|
|
991
|
+
`gtd: ${paths.length} untracked files (limit ${UNTRACKED_LIMIT}); largest untracked directories: ${top.map(([d, n]) => `${d} (${n})`).join(", ")}. Add them to .gitignore or delete them.`,
|
|
992
|
+
),
|
|
993
|
+
)
|
|
994
|
+
})
|
|
995
|
+
|
|
974
996
|
// Only an approval gate counts an index-only edit (one staged, then reverted
|
|
975
997
|
// in the worktree): its approval must cover exactly the judged diff. Every
|
|
976
998
|
// other rest keeps the worktree's view, so a net-zero turn stays an attempt.
|
package/src/edge/index.ts
CHANGED
package/src/platform/Git.ts
CHANGED
|
@@ -94,6 +94,8 @@ export interface GitReaderOperations {
|
|
|
94
94
|
readonly changedPaths: (
|
|
95
95
|
base?: string,
|
|
96
96
|
) => Effect.Effect<ReadonlyArray<{ readonly path: string; readonly status: string }>, Error>
|
|
97
|
+
/** Untracked, non-ignored paths. */
|
|
98
|
+
readonly untrackedPaths: () => Effect.Effect<ReadonlyArray<string>, Error>
|
|
97
99
|
/**
|
|
98
100
|
* The index vs `HEAD`, as `{path, status}` pairs — what a commit would carry
|
|
99
101
|
* that `changedPaths` misses when the worktree reverted a staged edit. Empty
|
|
@@ -205,6 +207,14 @@ const stripCommitSeam = (tail: string): string => {
|
|
|
205
207
|
|
|
206
208
|
type GitExec = (...args: [string, ...Array<string>]) => Effect.Effect<string, Error>
|
|
207
209
|
|
|
210
|
+
// Unbounded argv throws E2BIG synchronously (a defect), so path lists are chunked.
|
|
211
|
+
const ARGV_CHUNK = 500
|
|
212
|
+
const chunked = <T>(items: ReadonlyArray<T>): Array<ReadonlyArray<T>> => {
|
|
213
|
+
const out: Array<ReadonlyArray<T>> = []
|
|
214
|
+
for (let i = 0; i < items.length; i += ARGV_CHUNK) out.push(items.slice(i, i + ARGV_CHUNK))
|
|
215
|
+
return out
|
|
216
|
+
}
|
|
217
|
+
|
|
208
218
|
/**
|
|
209
219
|
* `path → blob object id` at `ref`; a path absent from the map doesn't exist
|
|
210
220
|
* there. An unresolvable `ref` (an empty repo) yields an empty map, so every
|
|
@@ -215,11 +225,17 @@ const blobsAtRef = (
|
|
|
215
225
|
ref: string,
|
|
216
226
|
paths: ReadonlyArray<string>,
|
|
217
227
|
): Effect.Effect<Map<string, string>, Error> =>
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
228
|
+
Effect.forEach(
|
|
229
|
+
chunked(paths),
|
|
230
|
+
(chunk) =>
|
|
231
|
+
exec("git", "ls-tree", "-r", "-z", ref, "--", ...chunk).pipe(
|
|
232
|
+
Effect.catch(() => Effect.succeed("")),
|
|
233
|
+
),
|
|
234
|
+
{ concurrency: 1 },
|
|
235
|
+
).pipe(
|
|
236
|
+
Effect.map((outs) => {
|
|
221
237
|
const blobs = new Map<string, string>()
|
|
222
|
-
for (const entry of splitNul
|
|
238
|
+
for (const entry of outs.flatMap(splitNul)) {
|
|
223
239
|
const tab = entry.indexOf("\t")
|
|
224
240
|
if (tab === -1) continue
|
|
225
241
|
const [, type, oid] = entry.slice(0, tab).split(" ")
|
|
@@ -246,16 +262,20 @@ const hashObjects = (
|
|
|
246
262
|
exec: GitExec,
|
|
247
263
|
paths: ReadonlyArray<string>,
|
|
248
264
|
): Effect.Effect<Map<string, string>, Error> =>
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
265
|
+
Effect.forEach(
|
|
266
|
+
chunked(paths),
|
|
267
|
+
(chunk) =>
|
|
268
|
+
exec("git", "hash-object", "--", ...chunk).pipe(
|
|
269
|
+
Effect.map((out) =>
|
|
253
270
|
out
|
|
254
271
|
.trim()
|
|
255
272
|
.split("\n")
|
|
256
|
-
.map((oid, i) => [
|
|
273
|
+
.map((oid, i) => [chunk[i] ?? "", oid.trim()] as const),
|
|
257
274
|
),
|
|
258
|
-
|
|
275
|
+
),
|
|
276
|
+
{ concurrency: 1 },
|
|
277
|
+
).pipe(
|
|
278
|
+
Effect.map((entries) => new Map(entries.flat())),
|
|
259
279
|
Effect.catch(() => Effect.succeed(new Map<string, string>())),
|
|
260
280
|
)
|
|
261
281
|
|
|
@@ -471,6 +491,9 @@ const makeGitImpl = (
|
|
|
471
491
|
return all
|
|
472
492
|
}),
|
|
473
493
|
|
|
494
|
+
untrackedPaths: () =>
|
|
495
|
+
exec("git", "ls-files", "--others", "--exclude-standard", "-z").pipe(Effect.map(splitNul)),
|
|
496
|
+
|
|
474
497
|
stagedPaths: () =>
|
|
475
498
|
exec("git", "diff", "--cached", "--name-status", "--no-renames", "-z", "HEAD").pipe(
|
|
476
499
|
Effect.catch(() => Effect.succeed("")),
|
|
@@ -74,10 +74,13 @@ export type SteeringAnchor =
|
|
|
74
74
|
| { readonly kind: "option"; readonly questionIndex: number; readonly index: number }
|
|
75
75
|
| { readonly kind: "paragraph"; readonly line: number }
|
|
76
76
|
|
|
77
|
+
/** `pick-never-clears`: a free-form pick-one tick only moves, so `checked: false` is refused by policy, not for a bad anchor. */
|
|
78
|
+
type SteeringRefusalReason = "anchor-not-found" | "id-collision" | "pick-never-clears"
|
|
79
|
+
|
|
77
80
|
/** `annotate`'s result: the edits to splice in, or a typed refusal — a stale/unresolvable `anchor`, or a derived note id that already names a definition. */
|
|
78
81
|
export type SteeringAnnotateResult =
|
|
79
82
|
| { readonly ok: true; readonly edits: readonly SteeringEdit[] }
|
|
80
|
-
| { readonly ok: false; readonly reason:
|
|
83
|
+
| { readonly ok: false; readonly reason: SteeringRefusalReason }
|
|
81
84
|
|
|
82
85
|
/** One item of a `list` block's `items` tree — recursive to whatever depth the source markdown nests. */
|
|
83
86
|
export interface BlockListItem {
|
|
@@ -234,7 +234,7 @@ describe("freeFormFormat.annotate", () => {
|
|
|
234
234
|
const attach = (content: string, text: string): string => {
|
|
235
235
|
const r = freeFormFormat.annotate(content, anchor, text)
|
|
236
236
|
if (!r.ok) throw new Error("refused")
|
|
237
|
-
return
|
|
237
|
+
return applyEditsExact(content, r.edits)
|
|
238
238
|
}
|
|
239
239
|
const created = attach("Paragraph one.\n", "first")
|
|
240
240
|
expect(created).toMatch(/\[\^na\w+\]:\n - H: first\n/)
|
|
@@ -309,3 +309,53 @@ describe("freeFormFormat — thread outline", () => {
|
|
|
309
309
|
])
|
|
310
310
|
})
|
|
311
311
|
})
|
|
312
|
+
|
|
313
|
+
describe("freeFormFormat — task tick", () => {
|
|
314
|
+
const tick = (content: string, line: number): string => {
|
|
315
|
+
const r = freeFormFormat.apply(content, { kind: "paragraph", line }, { checked: true })
|
|
316
|
+
if (!r.ok) throw new Error(r.reason)
|
|
317
|
+
return applyEditsExact(content, r.edits)
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
it("ticks one box, leaving every other line byte-identical", () => {
|
|
321
|
+
const doc = "Tick exactly one event:\n\n- [ ] APPROVE\n- [ ] REJECT\n- [ ] CANCEL\n"
|
|
322
|
+
expect(tick(doc, 2)).toBe(doc.replace("- [ ] APPROVE", "- [x] APPROVE"))
|
|
323
|
+
})
|
|
324
|
+
|
|
325
|
+
it("moves the pick within one list only, leaving nested and other lists alone", () => {
|
|
326
|
+
const doc = "- [x] a\n - [x] n1\n - [ ] n2\n- [ ] b\n\n1. [x] other\n2. [ ] other2\n"
|
|
327
|
+
expect(tick(doc, 2)).toBe(
|
|
328
|
+
"- [x] a\n - [ ] n1\n - [x] n2\n- [ ] b\n\n1. [x] other\n2. [ ] other2\n",
|
|
329
|
+
)
|
|
330
|
+
expect(tick(doc, 3)).toBe(
|
|
331
|
+
"- [ ] a\n - [x] n1\n - [ ] n2\n- [x] b\n\n1. [x] other\n2. [ ] other2\n",
|
|
332
|
+
)
|
|
333
|
+
})
|
|
334
|
+
|
|
335
|
+
it("ticks a box that sits on the line after a bare marker", () => {
|
|
336
|
+
const doc = "-\n [ ] a\n-\n [x] b\n"
|
|
337
|
+
expect(tick(doc, 0)).toBe("-\n [x] a\n-\n [ ] b\n")
|
|
338
|
+
})
|
|
339
|
+
|
|
340
|
+
it("ticks an ordered item and keeps CRLF; a ticked box is a no-op; a fenced box refuses", () => {
|
|
341
|
+
expect(tick("1. [ ] a\r\n2. [ ] b\r\n", 1)).toBe("1. [ ] a\r\n2. [x] b\r\n")
|
|
342
|
+
const done = "- [x] a\n- [ ] b\n"
|
|
343
|
+
expect(tick(done, 0)).toBe(done)
|
|
344
|
+
expect(
|
|
345
|
+
freeFormFormat.apply("```\n- [ ] a\n```\n", { kind: "paragraph", line: 1 }, { checked: true })
|
|
346
|
+
.ok,
|
|
347
|
+
).toBe(false)
|
|
348
|
+
})
|
|
349
|
+
|
|
350
|
+
it("refuses to clear a box, whatever the anchor, and a tick round-trips byte-exact", () => {
|
|
351
|
+
const doc = "- [x] a\n- [ ] b\n"
|
|
352
|
+
expect(freeFormFormat.apply(doc, { kind: "paragraph", line: 0 }, { checked: false })).toEqual({
|
|
353
|
+
ok: false,
|
|
354
|
+
reason: "pick-never-clears",
|
|
355
|
+
})
|
|
356
|
+
const moved = tick(doc, 1)
|
|
357
|
+
expect(moved).toBe("- [ ] a\n- [x] b\n")
|
|
358
|
+
const back = freeFormFormat.apply(moved, { kind: "paragraph", line: 0 }, { checked: true })
|
|
359
|
+
expect(back.ok && applyEditsExact(moved, back.edits)).toBe(doc)
|
|
360
|
+
})
|
|
361
|
+
})
|
package/src/steering/freeform.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ListItem } from "mdast"
|
|
1
2
|
import { blockNodesOf } from "./Blocks.js"
|
|
2
3
|
import { eolOf } from "./Eol.js"
|
|
3
4
|
import {
|
|
@@ -93,6 +94,41 @@ const appendEdit = (content: string, text: string): SteeringEdit => {
|
|
|
93
94
|
return wholeDocumentEdit(content, newText)
|
|
94
95
|
}
|
|
95
96
|
|
|
97
|
+
/** Every task-list item with its sibling task items (direct children of one list only). */
|
|
98
|
+
const taskGroups = (node: unknown, out: ListItem[][]): void => {
|
|
99
|
+
const n = node as { type?: string; children?: unknown[] }
|
|
100
|
+
if (n.type === "list") {
|
|
101
|
+
out.push((n.children as ListItem[]).filter((i) => i.checked === true || i.checked === false))
|
|
102
|
+
}
|
|
103
|
+
for (const c of n.children ?? []) taskGroups(c, out)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The box sits on the first child's line, which is not the marker line when a bare `-` precedes it. */
|
|
107
|
+
const boxEdit = (lines: string[], item: ListItem, newText: string): SteeringEdit | undefined => {
|
|
108
|
+
const line = toLspPosition((item.children[0]?.position ?? item.position!).start).line
|
|
109
|
+
const m = /\[( |x|X)\]/.exec(lines[line] ?? "")
|
|
110
|
+
if (!m) return undefined
|
|
111
|
+
const c = m.index + 1
|
|
112
|
+
return { range: { start: { line, character: c }, end: { line, character: c + 1 } }, newText }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Pick-one tick: sets the box on `line` and unticks its ticked siblings, in one edit set. Ticking an already-ticked box is an empty edit set. */
|
|
116
|
+
const tickEdits = (content: string, line: number): SteeringAnnotateResult => {
|
|
117
|
+
const lines = content.split("\n")
|
|
118
|
+
const groups: ListItem[][] = []
|
|
119
|
+
taskGroups(parseMarkdown(content), groups)
|
|
120
|
+
const startLine = (i: ListItem): number => toLspPosition(i.position!.start).line
|
|
121
|
+
const group = groups.find((g) => g.some((i) => startLine(i) === line))
|
|
122
|
+
if (!group) return { ok: false, reason: "anchor-not-found" }
|
|
123
|
+
const target = group.find((i) => startLine(i) === line)!
|
|
124
|
+
if (target.checked === true) return { ok: true, edits: [] }
|
|
125
|
+
const edits = group
|
|
126
|
+
.filter((i) => i === target || i.checked === true)
|
|
127
|
+
.map((i) => boxEdit(lines, i, i === target ? "x" : " "))
|
|
128
|
+
if (edits.some((e) => e === undefined)) return { ok: false, reason: "anchor-not-found" }
|
|
129
|
+
return { ok: true, edits: edits as SteeringEdit[] }
|
|
130
|
+
}
|
|
131
|
+
|
|
96
132
|
/**
|
|
97
133
|
* Free-form's `apply`: only a `paragraph` anchor resolves (the only kind
|
|
98
134
|
* `view` ever reports), and it only ever APPENDS, at or past the document's
|
|
@@ -104,6 +140,8 @@ const appendEdit = (content: string, text: string): SteeringEdit => {
|
|
|
104
140
|
*/
|
|
105
141
|
const freeFormApply: SteeringFormat["apply"] = (content, anchor, opts): SteeringAnnotateResult => {
|
|
106
142
|
if (anchor.kind !== "paragraph") return { ok: false, reason: "anchor-not-found" }
|
|
143
|
+
if (opts.checked === true) return tickEdits(content, anchor.line)
|
|
144
|
+
if (opts.checked === false) return { ok: false, reason: "pick-never-clears" }
|
|
107
145
|
const text = opts.text ?? ""
|
|
108
146
|
const tree = parseMarkdown(content)
|
|
109
147
|
const lines = content.split(/\r?\n/)
|
|
@@ -77,6 +77,8 @@ const makeGitReaderOps = (repo: InMemRepo, root: string): GitReaderOperations =>
|
|
|
77
77
|
|
|
78
78
|
changedPaths: (base?: string) => Effect.succeed(repo.changedPathsWorktree(base)),
|
|
79
79
|
|
|
80
|
+
untrackedPaths: () => Effect.succeed(repo.untrackedPaths()),
|
|
81
|
+
|
|
80
82
|
stagedPaths: () => Effect.succeed(repo.stagedPaths()),
|
|
81
83
|
|
|
82
84
|
diffPaths: (base: string, tip: string) => tryCatch(() => repo.diffPaths(base, tip)),
|