@pmelab/gtd 60.1.0 → 61.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 +7 -0
- package/dist/chunks/{testkit-BhTey1Do.mjs → testkit-C9sq0x07.mjs} +1695 -1454
- package/dist/chunks/{wire-DZ8R1RU9.mjs → wire-CPNSm_9N.mjs} +27 -12
- package/dist/gtd.bundle.mjs +1666 -1449
- package/dist/gtd.core.mjs +86 -86
- package/dist/testkit/gherkin.mjs +1 -1
- package/dist/testkit.mjs +1 -1
- package/dist/wire.mjs +2 -2
- package/package.json +1 -1
- package/src/cli/History.test.ts +10 -1
- package/src/cli/Metrics.test.ts +10 -1
- package/src/cli/Program.test.ts +30 -19
- package/src/cli/Program.ts +4 -1
- package/src/cli/Strays.ts +3 -2
- package/src/edge/Edge.test.ts +78 -25
- package/src/edge/Edge.ts +161 -28
- package/src/emit/GitScript.ts +21 -3
- package/src/emit/index.ts +3 -0
- package/src/gherkin/messages.test.ts +1 -0
- package/src/gherkin/module-config.feature +1 -0
- package/src/gherkin/more-steps.feature +1 -0
- package/src/gherkin/testkit.feature +2 -1
- package/src/install/Install.ts +9 -2
- package/src/replay/Format.test.ts +35 -8
- package/src/replay/Format.ts +7 -4
- package/src/replay/Replay.test.ts +54 -0
- package/src/replay/Replay.ts +61 -17
- package/src/replay/Trailers.test.ts +12 -12
- package/src/replay/Trailers.ts +35 -20
- package/src/replay/Tree.ts +11 -0
- package/src/replay/index.ts +3 -0
- package/src/step/LandStep.ts +1 -0
- package/src/step/RepoSnapshot.ts +2 -0
- package/src/step/planStart.test.ts +2 -2
- package/src/step/planStep.test.ts +4 -4
- package/src/step/planStep.ts +10 -8
- package/src/testkit/TestRepo.test.ts +4 -1
- package/src/testkit/TestRepo.ts +3 -1
- package/src/twins/EmittedScriptRecognizer.ts +32 -1
- package/src/twins/GitTiers.ts +23 -0
- package/src/twins/InMemRepo.ts +14 -0
- package/src/twins/PinBeats.ts +33 -0
- package/src/twins/index.ts +1 -0
- package/src/wire/BeatDocument.ts +26 -5
- package/src/wire/Demand.ts +16 -6
- package/src/wire/constants.ts +3 -0
- package/src/wire/types.ts +4 -0
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { C as moveScript, D as revertScript, E as restoreScript, H as installContext, K as markedOptions, O as showScript, S as checkScript, T as removeScript, Y as protocolMismatch, i as configure, k as writeScript$1, n as configProblems, o as isFactoryWorkflow, p as numeric, s as answered, t as flows_exports, x as applyScript } from "./flows-DENt0uWV.mjs";
|
|
2
|
-
import { O as ACCESS_REFUSAL, a as renderScriptJson, d as renderLandPlain, f as DEMAND_BRIEFING, g as statusOf, h as renderBeatJson, i as judgeJson,
|
|
2
|
+
import { M as renderFormat, O as ACCESS_REFUSAL, a as renderScriptJson, d as renderLandPlain, f as DEMAND_BRIEFING, g as statusOf, h as renderBeatJson, i as judgeJson, k as PIN_PATH, l as noopText, m as beatDocument, n as resolveSession, o as scriptFields, p as renderBeatPlain, r as selectPath, s as landFields, t as globMatches, u as renderLandJson, v as beatKindOf, y as demandOf } from "./wire-CPNSm_9N.mjs";
|
|
3
3
|
import { M as headingSectionBodies, N as formatFinding, a as sections, c as openThreads, i as sectionBodies, l as steeringFormatFor, o as threads, r as openQuestions, s as BUILT_IN_MODE_NAMES, t as parseCodeThreads, u as unansweredQuestions } from "./steering-DF_BMadH.mjs";
|
|
4
4
|
import { createRequire, isBuiltin } from "node:module";
|
|
5
5
|
import * as Crypto from "node:crypto";
|
|
@@ -22274,7 +22274,13 @@ The \`kind\` field (\`--json=kind\`) selects what to do:
|
|
|
22274
22274
|
Land it immediately, no display needed.
|
|
22275
22275
|
- \`script\` — run \`--json=content\` with \`sh -c\`, appending its output to
|
|
22276
22276
|
\`--json=log\`, and ignore its exit code — the outcome lives in the tree,
|
|
22277
|
-
not in the exit code.
|
|
22277
|
+
not in the exit code. Two script beats carry a \`pin\` mark: the **pin
|
|
22278
|
+
beat** right after a process starts (\`pin: "add"\`, its script writes
|
|
22279
|
+
\`gtd bundle\`'s output to \`.gtd/process.gtdpin\`) and the **unpin beat**
|
|
22280
|
+
once the flow has ended (\`pin: "remove"\`, its script deletes that file).
|
|
22281
|
+
Both run unattended, like any script beat. A driver may write or delete the
|
|
22282
|
+
file its own way instead of running the script; either way it then lands,
|
|
22283
|
+
and the unpin's landing is the commit that closes the process.
|
|
22278
22284
|
- \`prompt\` — send plain \`gtd next\`'s own output to your agent CLI over
|
|
22279
22285
|
stdin (it's the bare content at this kind, so no separate \`--json=content\`
|
|
22280
22286
|
read is needed), using the accompanying \`session.id\`/\`session.resume\`/
|
|
@@ -22317,7 +22323,8 @@ skills, \`skills\` (an array of the skill names, already trimmed and split —
|
|
|
22317
22323
|
\`label\`, \`file\`, \`mode\`, \`edges\`, \`judge\` (the pending judgment's rendered
|
|
22318
22324
|
document). When a cost has been recorded (a prior \`gtd land --cost=<n>\`):
|
|
22319
22325
|
\`cost\`, \`costByModel\`. Always \`approvals\` (an array of
|
|
22320
|
-
\`{step, name, email, commit}\`, \`[]\` when none).
|
|
22326
|
+
\`{step, name, email, commit}\`, \`[]\` when none). Only at a pin or unpin
|
|
22327
|
+
beat: \`pin\` (\`"add"\` or \`"remove"\`).
|
|
22321
22328
|
|
|
22322
22329
|
\`gtd land\` fields, in fixed order: \`schema\`, \`script\` (the POSIX sh to run), \`settled\`
|
|
22323
22330
|
(true for a no-op at a \`script\` rest — stop immediately, nothing more to
|
|
@@ -22792,11 +22799,20 @@ const diffTrees$1 = (before, after) => {
|
|
|
22792
22799
|
};
|
|
22793
22800
|
};
|
|
22794
22801
|
const isEmptyDiff = (diff) => diff.added.length === 0 && diff.modified.length === 0 && diff.deleted.length === 0;
|
|
22802
|
+
const withoutPath = (tree, hidden) => {
|
|
22803
|
+
let paths;
|
|
22804
|
+
return {
|
|
22805
|
+
paths: () => paths ??= tree.paths().filter((path) => path !== hidden),
|
|
22806
|
+
read: (path) => path === hidden ? void 0 : tree.read(path),
|
|
22807
|
+
...tree.id !== void 0 ? { id: (path) => path === hidden ? void 0 : tree.id(path) } : {}
|
|
22808
|
+
};
|
|
22809
|
+
};
|
|
22810
|
+
const OLDEST_READABLE_FORMAT = 1;
|
|
22795
22811
|
const short$1 = (hash) => hash.slice(0, 7);
|
|
22796
22812
|
const formatFault = (commits) => {
|
|
22797
|
-
const foreign = commits.find((c) => c.format
|
|
22813
|
+
const foreign = commits.find((c) => c.format < OLDEST_READABLE_FORMAT || c.format > 2);
|
|
22798
22814
|
if (foreign === void 0) return void 0;
|
|
22799
|
-
return `gtd: commit ${short$1(foreign.hash)} is written in history format ${foreign.format}, but this gtd reads
|
|
22815
|
+
return `gtd: commit ${short$1(foreign.hash)} is written in history format ${foreign.format}, but this gtd reads formats ${OLDEST_READABLE_FORMAT} to 2 — run a gtd release that reads format ${foreign.format} to continue this process`;
|
|
22800
22816
|
};
|
|
22801
22817
|
//#endregion
|
|
22802
22818
|
//#region src/replay/Trailers.ts
|
|
@@ -22833,6 +22849,11 @@ const parseStepId = (raw) => {
|
|
|
22833
22849
|
occurrence: Number(match[2])
|
|
22834
22850
|
};
|
|
22835
22851
|
};
|
|
22852
|
+
/** What a pin and an unpin beat rest at, and their commits name — neither is a step of the workflow. */
|
|
22853
|
+
const PIN_STATES = {
|
|
22854
|
+
add: "pin",
|
|
22855
|
+
remove: "unpin"
|
|
22856
|
+
};
|
|
22836
22857
|
const isVerdict$1 = (value) => {
|
|
22837
22858
|
if (typeof value !== "object" || value === null) return false;
|
|
22838
22859
|
const v = value;
|
|
@@ -22930,6 +22951,9 @@ const TRAILER_READERS = {
|
|
|
22930
22951
|
"Gtd-Approved": (value, into) => {
|
|
22931
22952
|
into.approved ??= decodeApproval(value);
|
|
22932
22953
|
},
|
|
22954
|
+
"Gtd-Pin": (value, into) => {
|
|
22955
|
+
if (value === "add" || value === "remove") into.pin ??= value;
|
|
22956
|
+
},
|
|
22933
22957
|
"Gtd-Format": (value, into) => {
|
|
22934
22958
|
if (FORMAT_RE.test(value)) into.format ??= Number(value);
|
|
22935
22959
|
},
|
|
@@ -22952,6 +22976,7 @@ const parseCommitMessage = (message) => {
|
|
|
22952
22976
|
version: void 0,
|
|
22953
22977
|
kept: void 0,
|
|
22954
22978
|
approved: void 0,
|
|
22979
|
+
pin: void 0,
|
|
22955
22980
|
judge: [],
|
|
22956
22981
|
marks: [],
|
|
22957
22982
|
vars: {},
|
|
@@ -22969,24 +22994,29 @@ const parseCommitMessage = (message) => {
|
|
|
22969
22994
|
};
|
|
22970
22995
|
const sortedEntries = (record = {}) => Object.entries(record).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
|
|
22971
22996
|
/** Trailer order is fixed so the same spec always yields the same bytes. */
|
|
22997
|
+
const trailer = (name, value) => value === void 0 ? [] : [`Gtd-${name}: ${value}`];
|
|
22998
|
+
const costValue = (cost) => cost === void 0 ? void 0 : `${cost.cost}${cost.model !== void 0 ? ` ${cost.model}` : ""}`;
|
|
22972
22999
|
const formatCommitMessage = (spec) => {
|
|
22973
|
-
const lines = [
|
|
22974
|
-
|
|
22975
|
-
|
|
22976
|
-
|
|
22977
|
-
|
|
22978
|
-
|
|
22979
|
-
|
|
22980
|
-
|
|
22981
|
-
|
|
22982
|
-
|
|
22983
|
-
|
|
22984
|
-
|
|
22985
|
-
|
|
23000
|
+
const lines = [
|
|
23001
|
+
...trailer("Step", spec.step && formatStepId(spec.step)),
|
|
23002
|
+
...trailer("Workflow", spec.workflow),
|
|
23003
|
+
...trailer("Review-Base", spec.reviewBase),
|
|
23004
|
+
...sortedEntries(spec.vars).map(([name, value]) => `Gtd-Var: ${name}=${value}`),
|
|
23005
|
+
...sortedEntries(spec.config).map(([path, hash]) => `Gtd-Config: ${hash} ${encodePath(path)}`),
|
|
23006
|
+
...trailer("Cost", costValue(spec.cost)),
|
|
23007
|
+
...(spec.judge ?? []).map((verdict) => `Gtd-Judge: ${JSON.stringify(verdict)}`),
|
|
23008
|
+
...(spec.marks ?? []).map((m) => `Gtd-Mark: ${m.name}=${m.value}`),
|
|
23009
|
+
...trailer("Approved", spec.approved && encodeApproval(spec.approved)),
|
|
23010
|
+
...trailer("Pin", spec.pin),
|
|
23011
|
+
...trailer("Branch", spec.branch),
|
|
23012
|
+
...trailer("Version", spec.version),
|
|
23013
|
+
`Gtd-Format: 2`
|
|
23014
|
+
];
|
|
22986
23015
|
return `${formatSubject(spec.actor, spec.to, spec.from)}\n\n${lines.join("\n")}`;
|
|
22987
23016
|
};
|
|
22988
23017
|
//#endregion
|
|
22989
23018
|
//#region src/replay/Replay.ts
|
|
23019
|
+
const UNPIN_STATE = PIN_STATES.remove;
|
|
22990
23020
|
const actorOfKind = (kind) => kind === "run" ? "check" : kind === "agent" ? "agent" : kind === "human" ? "human" : "judge";
|
|
22991
23021
|
const memoryScopeOf = (name) => {
|
|
22992
23022
|
const dot = name.lastIndexOf(".");
|
|
@@ -23120,7 +23150,33 @@ const modeWithoutFile = (step) => {
|
|
|
23120
23150
|
if (options.mode === void 0 || options.file !== void 0) return void 0;
|
|
23121
23151
|
return `gtd: step "${step.name}": mode requires file in ${step.kind}() options — name the steering file the mode checks`;
|
|
23122
23152
|
};
|
|
23123
|
-
const
|
|
23153
|
+
const withoutPin = (input) => {
|
|
23154
|
+
const hide = (tree) => withoutPath(tree, PIN_PATH);
|
|
23155
|
+
return {
|
|
23156
|
+
...input,
|
|
23157
|
+
episode: {
|
|
23158
|
+
base: {
|
|
23159
|
+
...input.episode.base,
|
|
23160
|
+
tree: hide(input.episode.base.tree)
|
|
23161
|
+
},
|
|
23162
|
+
commits: input.episode.commits.map((c) => ({
|
|
23163
|
+
...c,
|
|
23164
|
+
tree: hide(c.tree),
|
|
23165
|
+
...c.kept !== void 0 ? { kept: {
|
|
23166
|
+
...c.kept,
|
|
23167
|
+
tree: hide(c.kept.tree)
|
|
23168
|
+
} } : {}
|
|
23169
|
+
}))
|
|
23170
|
+
},
|
|
23171
|
+
...input.startTree !== void 0 ? { startTree: hide(input.startTree) } : {},
|
|
23172
|
+
...input.pending !== void 0 ? { pending: {
|
|
23173
|
+
...input.pending,
|
|
23174
|
+
tree: hide(input.pending.tree)
|
|
23175
|
+
} } : {}
|
|
23176
|
+
};
|
|
23177
|
+
};
|
|
23178
|
+
const replay = async (raw) => {
|
|
23179
|
+
const input = withoutPin(raw);
|
|
23124
23180
|
const commits = input.episode.commits.map((c) => ({
|
|
23125
23181
|
...c,
|
|
23126
23182
|
parsed: parseCommitMessage(c.message)
|
|
@@ -23129,6 +23185,9 @@ const replay = async (input) => {
|
|
|
23129
23185
|
let cursor = 0;
|
|
23130
23186
|
let pendingUsed = false;
|
|
23131
23187
|
let position = input.episode.base;
|
|
23188
|
+
const skipPins = () => {
|
|
23189
|
+
while (cursor < commits.length && commits[cursor].parsed.pin !== void 0) cursor++;
|
|
23190
|
+
};
|
|
23132
23191
|
let previousPosition = input.episode.base;
|
|
23133
23192
|
let expectedNext;
|
|
23134
23193
|
const occurrences = /* @__PURE__ */ new Map();
|
|
@@ -23310,19 +23369,24 @@ const replay = async (input) => {
|
|
|
23310
23369
|
if (unapproved !== void 0) return finish(unapproved);
|
|
23311
23370
|
cursor++;
|
|
23312
23371
|
advance(commit);
|
|
23313
|
-
|
|
23314
|
-
|
|
23315
|
-
hash: commit.kept.hash,
|
|
23316
|
-
before: commit.tree,
|
|
23317
|
-
after: commit.kept.tree
|
|
23318
|
-
});
|
|
23372
|
+
skipPins();
|
|
23373
|
+
noteKept(step, commit);
|
|
23319
23374
|
const to = commit.parsed.parsed?.to;
|
|
23320
|
-
if (to !== void 0) expectedNext = {
|
|
23375
|
+
if (to !== void 0 && to !== UNPIN_STATE) expectedNext = {
|
|
23321
23376
|
name: to,
|
|
23322
23377
|
hash: commit.hash
|
|
23323
23378
|
};
|
|
23324
23379
|
return replyFor(step.request, commit.parsed.judge);
|
|
23325
23380
|
};
|
|
23381
|
+
const noteKept = (step, commit) => {
|
|
23382
|
+
if (commit.parsed.kept === void 0) return;
|
|
23383
|
+
if (commit.kept === void 0) keptTips.delete(step.name);
|
|
23384
|
+
else keptTips.set(step.name, {
|
|
23385
|
+
hash: commit.kept.hash,
|
|
23386
|
+
before: commit.tree,
|
|
23387
|
+
after: commit.kept.tree
|
|
23388
|
+
});
|
|
23389
|
+
};
|
|
23326
23390
|
const landPending = (step, pending) => {
|
|
23327
23391
|
pendingUsed = true;
|
|
23328
23392
|
landed = step;
|
|
@@ -23339,7 +23403,10 @@ const replay = async (input) => {
|
|
|
23339
23403
|
return replyFor(step.request, pending.verdicts ?? []);
|
|
23340
23404
|
};
|
|
23341
23405
|
const answer = (step) => {
|
|
23342
|
-
while (cursor < commits.length && isAttemptAt(commits[cursor], step.name))
|
|
23406
|
+
while (cursor < commits.length && isAttemptAt(commits[cursor], step.name)) {
|
|
23407
|
+
cursor++;
|
|
23408
|
+
skipPins();
|
|
23409
|
+
}
|
|
23343
23410
|
const commit = commits[cursor];
|
|
23344
23411
|
if (commit !== void 0) return consumeCommit(step, commit);
|
|
23345
23412
|
if (input.pending === void 0 || pendingUsed) return finish({
|
|
@@ -23352,6 +23419,7 @@ const replay = async (input) => {
|
|
|
23352
23419
|
return landPending(step, input.pending);
|
|
23353
23420
|
};
|
|
23354
23421
|
const requestFault = (request) => {
|
|
23422
|
+
if (Object.values(PIN_STATES).includes(scoped(request.name))) return `gtd: step "${scoped(request.name)}": the name is reserved for the pin and unpin beats — rename the step`;
|
|
23355
23423
|
if (request.kind === "run" && typeof request.body !== "string") return `gtd: step "${scoped(request.name)}": a run() body is a shell script string — decide in flow code, then render the script (see checkScript and friends)`;
|
|
23356
23424
|
if (request.kind !== "agent") return void 0;
|
|
23357
23425
|
const name = scoped(request.name);
|
|
@@ -23378,15 +23446,18 @@ const replay = async (input) => {
|
|
|
23378
23446
|
const blocker = blockerAt(step);
|
|
23379
23447
|
return blocker === void 0 ? answer(step) : finish(blocker);
|
|
23380
23448
|
};
|
|
23381
|
-
const endOfFlow = (via) =>
|
|
23382
|
-
|
|
23383
|
-
|
|
23384
|
-
|
|
23385
|
-
|
|
23386
|
-
|
|
23387
|
-
|
|
23388
|
-
|
|
23389
|
-
|
|
23449
|
+
const endOfFlow = (via) => {
|
|
23450
|
+
skipPins();
|
|
23451
|
+
return cursor < commits.length ? {
|
|
23452
|
+
kind: "divergence",
|
|
23453
|
+
message: `gtd: the flow ended, but commit ${short(commits[cursor].hash)} continues the episode — ${DIVERGED}`
|
|
23454
|
+
} : {
|
|
23455
|
+
kind: "ended",
|
|
23456
|
+
via,
|
|
23457
|
+
trace,
|
|
23458
|
+
landed,
|
|
23459
|
+
marks
|
|
23460
|
+
};
|
|
23390
23461
|
};
|
|
23391
23462
|
const changesSince = (hash) => {
|
|
23392
23463
|
const tree = treeAt(hash);
|
|
@@ -23449,10 +23520,11 @@ const replay = async (input) => {
|
|
|
23449
23520
|
skillsFor: (localName) => resolveSkills(memoryScopeOf(scoped(localName))) ?? [],
|
|
23450
23521
|
accessFor: (localName, file) => foldedAccessFor(resolveAccess(memoryScopeOf(scoped(localName))), file),
|
|
23451
23522
|
head: () => {
|
|
23452
|
-
const rest = commits.slice(cursor);
|
|
23523
|
+
const rest = commits.slice(cursor).filter((c) => c.parsed.pin === void 0);
|
|
23453
23524
|
return rest.length > 0 && rest.every((c) => c.parsed.step === void 0) ? rest[rest.length - 1].hash : position.hash;
|
|
23454
23525
|
}
|
|
23455
23526
|
};
|
|
23527
|
+
skipPins();
|
|
23456
23528
|
installContext(context);
|
|
23457
23529
|
try {
|
|
23458
23530
|
const flowDone = input.flow({}).then(() => {
|
|
@@ -24522,1546 +24594,1672 @@ const pin = (module, root, version) => gen(function* () {
|
|
|
24522
24594
|
return Buffer.concat([Buffer.from(`gtdpin ${PIN_FORMAT} gtd ${version}\n`), gzipSync(source, { level: 9 })]);
|
|
24523
24595
|
});
|
|
24524
24596
|
//#endregion
|
|
24525
|
-
//#region src/
|
|
24597
|
+
//#region src/emit/GitScript.ts
|
|
24598
|
+
const shellQuote = (value) => `'${value.replace(/'/g, "'\\''")}'`;
|
|
24526
24599
|
/**
|
|
24527
|
-
*
|
|
24528
|
-
*
|
|
24529
|
-
*
|
|
24600
|
+
* `--allow-empty` because gtd's workflow commits may be empty on purpose.
|
|
24601
|
+
* Mirrors `Git.ts`'s retry: only re-tries without hooks on the specific
|
|
24602
|
+
* "empty git commit" rejection message, discriminated from the captured
|
|
24603
|
+
* output rather than the exit code, so a genuinely rejected commit (a lint
|
|
24604
|
+
* error, a commit-msg hook) still fails.
|
|
24530
24605
|
*/
|
|
24531
|
-
const
|
|
24532
|
-
|
|
24533
|
-
|
|
24534
|
-
|
|
24535
|
-
|
|
24536
|
-
|
|
24537
|
-
|
|
24538
|
-
|
|
24539
|
-
|
|
24540
|
-
|
|
24541
|
-
|
|
24542
|
-
kind: "noop",
|
|
24543
|
-
state: snapshot.state,
|
|
24544
|
-
settled: landing.settled
|
|
24545
|
-
};
|
|
24546
|
-
const cost = opts.cost === void 0 ? {} : { cost: {
|
|
24547
|
-
cost: opts.cost,
|
|
24548
|
-
...opts.model !== void 0 ? { model: opts.model } : {}
|
|
24549
|
-
} };
|
|
24550
|
-
if (landing.kind === "attempt") {
|
|
24551
|
-
const message = formatCommitMessage({
|
|
24552
|
-
actor: snapshot.actor,
|
|
24553
|
-
to: snapshot.state,
|
|
24554
|
-
...cost
|
|
24555
|
-
});
|
|
24556
|
-
return {
|
|
24557
|
-
kind: "commit",
|
|
24558
|
-
state: snapshot.state,
|
|
24559
|
-
to: snapshot.state,
|
|
24560
|
-
subject: landing.subject,
|
|
24561
|
-
steps: [{
|
|
24562
|
-
kind: "gitWrite",
|
|
24563
|
-
write: {
|
|
24564
|
-
kind: "commitAll",
|
|
24565
|
-
message
|
|
24566
|
-
}
|
|
24567
|
-
}, {
|
|
24568
|
-
kind: "outcome",
|
|
24569
|
-
outcome: {
|
|
24570
|
-
kind: "commit",
|
|
24571
|
-
subject: landing.subject
|
|
24572
|
-
}
|
|
24573
|
-
}]
|
|
24574
|
-
};
|
|
24575
|
-
}
|
|
24576
|
-
const message = formatCommitMessage({
|
|
24577
|
-
...landing.spec,
|
|
24578
|
-
...cost,
|
|
24579
|
-
...opts.judge !== void 0 && opts.judge.length > 0 ? { judge: opts.judge } : {}
|
|
24580
|
-
});
|
|
24581
|
-
const subject = message.split("\n")[0];
|
|
24582
|
-
const outcome = landing.to === snapshot.state ? {
|
|
24583
|
-
kind: "outcome",
|
|
24584
|
-
outcome: {
|
|
24585
|
-
kind: "commit",
|
|
24586
|
-
subject
|
|
24587
|
-
}
|
|
24588
|
-
} : {
|
|
24589
|
-
kind: "outcome",
|
|
24590
|
-
outcome: {
|
|
24591
|
-
kind: "transition",
|
|
24592
|
-
from: snapshot.state,
|
|
24593
|
-
to: landing.to
|
|
24594
|
-
}
|
|
24595
|
-
};
|
|
24596
|
-
return {
|
|
24597
|
-
kind: "commit",
|
|
24598
|
-
state: snapshot.state,
|
|
24599
|
-
to: landing.to,
|
|
24600
|
-
subject,
|
|
24601
|
-
steps: [
|
|
24602
|
-
...uncheckStep(snapshot),
|
|
24603
|
-
{
|
|
24604
|
-
kind: "gitWrite",
|
|
24605
|
-
write: landing.keepTurn !== void 0 ? {
|
|
24606
|
-
kind: "keepTurn",
|
|
24607
|
-
ref: landing.keepTurn,
|
|
24608
|
-
message
|
|
24609
|
-
} : landing.spec.approved !== void 0 ? {
|
|
24610
|
-
kind: "commitClean",
|
|
24611
|
-
message
|
|
24612
|
-
} : {
|
|
24613
|
-
kind: "commitAll",
|
|
24614
|
-
message
|
|
24615
|
-
}
|
|
24616
|
-
},
|
|
24617
|
-
...landing.keepHistory !== void 0 ? [{
|
|
24618
|
-
kind: "gitWrite",
|
|
24619
|
-
write: {
|
|
24620
|
-
kind: "updateRef",
|
|
24621
|
-
ref: landing.keepHistory,
|
|
24622
|
-
target: "HEAD"
|
|
24623
|
-
}
|
|
24624
|
-
}] : [],
|
|
24625
|
-
outcome
|
|
24626
|
-
]
|
|
24627
|
-
};
|
|
24606
|
+
const commitAllowEmpty = (message, only) => {
|
|
24607
|
+
const m = shellQuote(message);
|
|
24608
|
+
const paths = only === void 0 ? "" : ` -- ${shellQuote(only)}`;
|
|
24609
|
+
return [
|
|
24610
|
+
`if ! out=$(git commit --allow-empty -m ${m}${paths} 2>&1); then`,
|
|
24611
|
+
` case "$out" in`,
|
|
24612
|
+
` *"empty git commit"*) git commit --allow-empty --no-verify -m ${m}${paths} || exit 1 ;;`,
|
|
24613
|
+
` *) printf '%s\\n' "$out" >&2; exit 1 ;;`,
|
|
24614
|
+
` esac`,
|
|
24615
|
+
`fi`
|
|
24616
|
+
].join("\n");
|
|
24628
24617
|
};
|
|
24629
|
-
|
|
24630
|
-
|
|
24618
|
+
const CHANGED_SINCE_LAND = "gtd: the working tree changed since `gtd land` — revert the edits and run `gtd land` again to approve";
|
|
24619
|
+
/** `git add -A || exit 1` (an `&&` left side would not abort under `set -e`) then `commitAllowEmpty`. */
|
|
24620
|
+
const commitAll = (message) => `git add -A || exit 1\n${commitAllowEmpty(message)}`;
|
|
24621
|
+
/** Commits `path` alone, as the worktree has it; every other pending change stays pending. */
|
|
24622
|
+
const commitPath = (path, message) => `git add -A -- ${shellQuote(path)} || exit 1\n${commitAllowEmpty(message, path)}`;
|
|
24623
|
+
/** A pin beat's script. A failed `gtd bundle` leaves no half-written pin behind. */
|
|
24624
|
+
const pinScript = (path) => {
|
|
24625
|
+
const p = shellQuote(path);
|
|
24626
|
+
const slash = path.lastIndexOf("/");
|
|
24627
|
+
return `${slash === -1 ? "" : `mkdir -p ${shellQuote(path.slice(0, slash))}\n`}gtd bundle >${p} || { rm -f -- ${p}; exit 1; }`;
|
|
24628
|
+
};
|
|
24629
|
+
/** An unpin beat's script, and `gtd abandon`'s removal of the pin. */
|
|
24630
|
+
const unpinScript = (path) => `rm -f -- ${shellQuote(path)}`;
|
|
24631
24631
|
/**
|
|
24632
|
-
*
|
|
24633
|
-
*
|
|
24634
|
-
* runs
|
|
24632
|
+
* An approval records exactly the judged tree: HEAD is pinned before the
|
|
24633
|
+
* clean check and the commit is built over its tree with `commit-tree`, so no
|
|
24634
|
+
* hook runs and none can add to it. A HEAD that moved fails the guarded
|
|
24635
|
+
* `update-ref`. `commit-tree` ignores `commit.gpgSign`, so it is read here.
|
|
24635
24636
|
*/
|
|
24636
|
-
const
|
|
24637
|
-
|
|
24638
|
-
|
|
24639
|
-
|
|
24637
|
+
const commitClean = (message) => [
|
|
24638
|
+
`gtd_head=$(git rev-parse --verify HEAD) || exit 1`,
|
|
24639
|
+
`if [ -n "$(git status --porcelain --untracked-files=all --ignore-submodules=none)" ]; then`,
|
|
24640
|
+
` printf '%s\\n' ${shellQuote(CHANGED_SINCE_LAND)} >&2`,
|
|
24641
|
+
` exit 1`,
|
|
24642
|
+
`fi`,
|
|
24643
|
+
`gtd_sign_cfg=$(git config --type=bool --default=false commit.gpgSign) || exit 1`,
|
|
24644
|
+
`if [ "$gtd_sign_cfg" = true ]; then gtd_sign=-S; else gtd_sign=--no-gpg-sign; fi`,
|
|
24645
|
+
`gtd_tip=$(git commit-tree "$gtd_sign" "$gtd_head^{tree}" -p "$gtd_head" -m ${shellQuote(message)}) || exit 1`,
|
|
24646
|
+
`git update-ref -m ${shellQuote(`commit: ${message}`)} HEAD "$gtd_tip" "$gtd_head" || exit 1`
|
|
24647
|
+
].join("\n");
|
|
24648
|
+
/** No current caller — see `Git.ts`'s `GitWriterOperations.commitAsIs` for why it's kept anyway. */
|
|
24649
|
+
const commitAsIs = (message) => commitAllowEmpty(message);
|
|
24650
|
+
/** No current caller — see `Git.ts`'s `GitWriterOperations.softResetTo` for why it's kept anyway. */
|
|
24651
|
+
const softResetTo = (ref) => `git reset --soft ${shellQuote(ref)}`;
|
|
24652
|
+
const mixedResetTo = (ref) => `git reset --mixed ${shellQuote(ref)}`;
|
|
24653
|
+
const hardResetTo = (ref) => `git reset --hard ${shellQuote(ref)}`;
|
|
24654
|
+
/**
|
|
24655
|
+
* `git add -A` then `git reset --hard HEAD` — discards every pending change,
|
|
24656
|
+
* tracked or untracked. Joined with `&&`: a failed stage must not reach the
|
|
24657
|
+
* hard reset, or untracked survivors remain — the exact outcome this builder
|
|
24658
|
+
* exists to avoid. No current caller — see `Git.ts`'s
|
|
24659
|
+
* `GitWriterOperations.discardPending` for why it's kept anyway.
|
|
24660
|
+
*/
|
|
24661
|
+
const discardPending = () => `git add -A && git reset --hard HEAD`;
|
|
24662
|
+
/**
|
|
24663
|
+
* POSIX sh (runs under dash). The marker is a `commit-tree` over HEAD's own
|
|
24664
|
+
* tree, so no hook runs on it and its tree is the pre-turn tree by
|
|
24665
|
+
* construction; `commit-tree` ignores `commit.gpgSign`, hence the explicit
|
|
24666
|
+
* `-S`, read (and failing closed) before any object or ref is written; the
|
|
24667
|
+
* index is already staged by then, so an early exit leaves the turn staged.
|
|
24668
|
+
* The kept ref is deleted only after `reset -q` succeeds: it is then the
|
|
24669
|
+
* turn's only handle.
|
|
24670
|
+
* The branch moves before the final hard reset, so if that reset fails the
|
|
24671
|
+
* marker has landed and the turn is still in the working tree (kept ref
|
|
24672
|
+
* present); every other failure leaves the branch untouched.
|
|
24673
|
+
*/
|
|
24674
|
+
const keepTurn = (refPrefix, message) => {
|
|
24675
|
+
const keptRef = `${shellQuote(`${refPrefix}/`)}"$gtd_kept"`;
|
|
24640
24676
|
return [
|
|
24641
|
-
|
|
24642
|
-
|
|
24643
|
-
`
|
|
24677
|
+
`git add -A || exit 1`,
|
|
24678
|
+
`gtd_sign_cfg=$(git config --type=bool --default=false commit.gpgSign) || exit 1`,
|
|
24679
|
+
`if [ "$gtd_sign_cfg" = true ]; then gtd_sign=-S; else gtd_sign=--no-gpg-sign; fi`,
|
|
24680
|
+
`gtd_head=$(git rev-parse --verify HEAD) || exit 1`,
|
|
24681
|
+
`gtd_tree=$(git write-tree) || exit 1`,
|
|
24682
|
+
`gtd_kept=$(git commit-tree "$gtd_tree" -p "$gtd_head" -m ${shellQuote(message)}) || exit 1`,
|
|
24683
|
+
`git update-ref ${keptRef} "$gtd_kept" || exit 1`,
|
|
24684
|
+
`gtd_marker=$(printf '%s\\nGtd-Kept: %s' ${shellQuote(message)} "$gtd_kept")`,
|
|
24685
|
+
`if ! gtd_tip=$(git commit-tree "$gtd_sign" "$gtd_head^{tree}" -p "$gtd_head" -m "$gtd_marker") || ! git update-ref -m ${shellQuote(`commit: ${message}`)} HEAD "$gtd_tip" "$gtd_head"; then`,
|
|
24686
|
+
` git reset -q && git update-ref -d ${keptRef}`,
|
|
24687
|
+
` exit 1`,
|
|
24688
|
+
`fi`,
|
|
24689
|
+
`git reset --hard HEAD`
|
|
24644
24690
|
].join("\n");
|
|
24645
24691
|
};
|
|
24646
|
-
|
|
24647
|
-
|
|
24648
|
-
const
|
|
24649
|
-
kind: "refusal",
|
|
24650
|
-
message
|
|
24651
|
-
});
|
|
24652
|
-
const checkOverrides = (commandLabel, varOverrides, declaredNames, envNames) => gen(function* () {
|
|
24653
|
-
const environmentOnly = Object.keys(varOverrides).filter((n) => !declaredNames.includes(n) && envNames.includes(n));
|
|
24654
|
-
if (environmentOnly.length > 0) return yield* fail$4(new GtdUsageError(`${commandLabel}: --var pins process settings only; ${environmentOnly.join(", ")} ${environmentOnly.length === 1 ? "is an environment setting" : "are environment settings"} — set it as a workflow option in gtd.config.ts or with a GTD_<NAME> environment variable`));
|
|
24655
|
-
const undeclared = Object.keys(varOverrides).filter((n) => !declaredNames.includes(n));
|
|
24656
|
-
if (undeclared.length > 0) return yield* fail$4(new GtdUsageError(`${commandLabel}: --var name(s) not declared by this workflow: ${undeclared.join(", ")} — declared: ${declaredNames.length > 0 ? declaredNames.join(", ") : "(none)"}`));
|
|
24657
|
-
});
|
|
24692
|
+
const updateRef = (ref, hash) => `git update-ref ${shellQuote(ref)} ${shellQuote(hash)}`;
|
|
24693
|
+
/** `git update-ref -d <ref>` — idempotent: deleting a missing ref is already a no-op in real git. */
|
|
24694
|
+
const deleteRef = (ref) => `git update-ref -d ${shellQuote(ref)}`;
|
|
24658
24695
|
/**
|
|
24659
|
-
*
|
|
24660
|
-
*
|
|
24661
|
-
* `Gtd-Workflow:` trailer, every process setting as a `Gtd-Var:` trailer, plus
|
|
24662
|
-
* a `Gtd-Review-Base:` trailer when the workflow fixes the process's diff base,
|
|
24663
|
-
* and a `Gtd-Config:` trailer per config file.
|
|
24664
|
-
* All validation is a REFUSAL, not an Effect failure.
|
|
24696
|
+
* No `+` on the refspec: a local ref the remote's tip does not descend from
|
|
24697
|
+
* fails the fetch instead of being overwritten (a fast-forward still applies).
|
|
24665
24698
|
*/
|
|
24666
|
-
const
|
|
24667
|
-
|
|
24668
|
-
|
|
24669
|
-
|
|
24670
|
-
|
|
24671
|
-
|
|
24672
|
-
|
|
24673
|
-
|
|
24674
|
-
|
|
24675
|
-
|
|
24676
|
-
|
|
24677
|
-
|
|
24678
|
-
|
|
24679
|
-
|
|
24680
|
-
|
|
24681
|
-
|
|
24682
|
-
|
|
24683
|
-
|
|
24684
|
-
|
|
24685
|
-
const base = template === void 0 ? void 0 : yield* resolveBase(commandLabel, template);
|
|
24686
|
-
if (typeof base === "object") return base;
|
|
24687
|
-
const subject = formatSubject(actor, firstStep);
|
|
24688
|
-
return {
|
|
24689
|
-
kind: "start",
|
|
24690
|
-
workflow: name,
|
|
24691
|
-
subject,
|
|
24692
|
-
steps: [{
|
|
24693
|
-
kind: "gitWrite",
|
|
24694
|
-
write: {
|
|
24695
|
-
kind: "commitAll",
|
|
24696
|
-
message: formatCommitMessage({
|
|
24697
|
-
actor,
|
|
24698
|
-
to: firstStep,
|
|
24699
|
-
workflow: name,
|
|
24700
|
-
...base !== void 0 ? { reviewBase: base } : {},
|
|
24701
|
-
vars,
|
|
24702
|
-
config: start.config
|
|
24703
|
-
})
|
|
24704
|
-
}
|
|
24705
|
-
}, {
|
|
24706
|
-
kind: "outcome",
|
|
24707
|
-
outcome: {
|
|
24708
|
-
kind: "commit",
|
|
24709
|
-
subject
|
|
24710
|
-
}
|
|
24711
|
-
}]
|
|
24712
|
-
};
|
|
24713
|
-
});
|
|
24714
|
-
/**
|
|
24715
|
-
* The workflow's diff base: `git merge-base <base> HEAD`, where a blank base
|
|
24716
|
-
* means the default branch. The merge-base is what gets pinned, so a base on
|
|
24717
|
-
* another branch reviews exactly this branch's own commits.
|
|
24718
|
-
*/
|
|
24719
|
-
const resolveBase = (commandLabel, value) => gen(function* () {
|
|
24720
|
-
const git = yield* GitService;
|
|
24721
|
-
const rendered = value.trim();
|
|
24722
|
-
const target = rendered === "" ? yield* git.defaultBranch() : rendered;
|
|
24723
|
-
const resolvedBase = yield* result(git.resolveRef(target));
|
|
24724
|
-
if (resolvedBase._tag === "Failure") return refusal(`${commandLabel}: "${target}" does not resolve to a commit`);
|
|
24725
|
-
const mergeBase = yield* git.mergeBase(resolvedBase.success, "HEAD");
|
|
24726
|
-
if (isNone(mergeBase)) return refusal(`${commandLabel}: "${target}" shares no common ancestor with HEAD`);
|
|
24727
|
-
if (mergeBase.value === (yield* git.resolveRef("HEAD"))) return refusal(`${commandLabel}: nothing to review: HEAD has no commits beyond ${target}`);
|
|
24728
|
-
return mergeBase.value;
|
|
24729
|
-
});
|
|
24730
|
-
//#endregion
|
|
24731
|
-
//#region src/step/strayPaths.ts
|
|
24732
|
-
/** Globs use the `access.write` dialect (`globMatches`). */
|
|
24733
|
-
const strayPaths = (touched, allow) => touched.filter((path) => !allow.some((glob) => globMatches(path, glob))).sort();
|
|
24734
|
-
//#endregion
|
|
24735
|
-
//#region src/edge/Edge.ts
|
|
24736
|
-
const EMPTY_TREE$1 = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
|
|
24737
|
-
const ACTORS = /* @__PURE__ */ new Set([
|
|
24738
|
-
"agent",
|
|
24739
|
-
"human",
|
|
24740
|
-
"check",
|
|
24741
|
-
"judge"
|
|
24742
|
-
]);
|
|
24743
|
-
/**
|
|
24744
|
-
* Where the episode HEAD belongs to begins, read off subjects alone. Walking
|
|
24745
|
-
* back from HEAD: a bare `gtd(human): <step>` commit with no `Gtd-Step` is what
|
|
24746
|
-
* `gtd start` writes — no landing ever produces one — and opens an
|
|
24747
|
-
* episode as its first commit (its `Gtd-Workflow` trailer names the workflow;
|
|
24748
|
-
* a legacy `--entry` opening has none); a commit that is not a gtd step commit, or one
|
|
24749
|
-
* entering the flow's first step (a finished episode), bounds the episode
|
|
24750
|
-
* from below.
|
|
24751
|
-
*/
|
|
24752
|
-
const locateEpisode = (def, history) => {
|
|
24753
|
-
for (let i = history.length - 1; i >= 0; i--) {
|
|
24754
|
-
const message = parseCommitMessage(history[i].message);
|
|
24755
|
-
const subject = message.parsed;
|
|
24756
|
-
if (subject !== void 0 && message.step === void 0 && subject.from === void 0 && subject.actor === "human") return {
|
|
24757
|
-
workflow: message.workflow,
|
|
24758
|
-
legacyOpening: message.workflow === void 0,
|
|
24759
|
-
baseIndex: i,
|
|
24760
|
-
processStart: i
|
|
24761
|
-
};
|
|
24762
|
-
if (subject === void 0 || !ACTORS.has(subject.actor) || subject.to === def.initial) return {
|
|
24763
|
-
workflow: void 0,
|
|
24764
|
-
legacyOpening: false,
|
|
24765
|
-
baseIndex: i,
|
|
24766
|
-
processStart: i + 1
|
|
24767
|
-
};
|
|
24699
|
+
const fetchHistoryRefs = (remote) => [
|
|
24700
|
+
`set -eu`,
|
|
24701
|
+
`before=$(git for-each-ref --format='x' 'refs/gtd/history/' | wc -l)`,
|
|
24702
|
+
`git fetch ${shellQuote(remote)} 'refs/gtd/history/*:refs/gtd/history/*'`,
|
|
24703
|
+
`after=$(git for-each-ref --format='x' 'refs/gtd/history/' | wc -l)`,
|
|
24704
|
+
`printf '%s history refs arrived\\n' "$((after - before))"`
|
|
24705
|
+
].join("\n");
|
|
24706
|
+
/** Marks an emitted block as print-only — mirrors `OutcomeScript.ts`'s `OUTCOME_MARKER` verbatim, so `src/twins/EmittedScriptRecognizer.ts` recognizes either path's output identically. */
|
|
24707
|
+
const OUTCOME_MARKER$1 = "# gtd: outcome (print-only)";
|
|
24708
|
+
const printfLine$1 = (fmt, args) => `${OUTCOME_MARKER$1}\nprintf ${shellQuote(fmt.replace(/\n/g, "\\n"))} ${args.join(" ")}`;
|
|
24709
|
+
const renderOutcome = (outcome) => {
|
|
24710
|
+
switch (outcome.kind) {
|
|
24711
|
+
case "transition": return printfLine$1("%s %s → %s\n", [
|
|
24712
|
+
"'->'",
|
|
24713
|
+
shellQuote(outcome.from),
|
|
24714
|
+
shellQuote(outcome.to)
|
|
24715
|
+
]);
|
|
24716
|
+
case "commit": return printfLine$1("%s %s\n", ["'[commit]'", shellQuote(outcome.subject)]);
|
|
24717
|
+
case "note": return printfLine$1("%s\n", [shellQuote(outcome.text)]);
|
|
24768
24718
|
}
|
|
24769
|
-
return {
|
|
24770
|
-
workflow: void 0,
|
|
24771
|
-
legacyOpening: false,
|
|
24772
|
-
baseIndex: -1,
|
|
24773
|
-
processStart: 0
|
|
24774
|
-
};
|
|
24775
|
-
};
|
|
24776
|
-
const headTurnOf = (head) => {
|
|
24777
|
-
if (head === void 0) return void 0;
|
|
24778
|
-
const message = parseCommitMessage(head.message);
|
|
24779
|
-
const subject = message.parsed;
|
|
24780
|
-
if (subject === void 0 || !ACTORS.has(subject.actor)) return void 0;
|
|
24781
|
-
return {
|
|
24782
|
-
state: subject.to,
|
|
24783
|
-
actor: subject.actor,
|
|
24784
|
-
empty: head.touched.length === 0,
|
|
24785
|
-
step: message.step !== void 0
|
|
24786
|
-
};
|
|
24787
|
-
};
|
|
24788
|
-
const traceEntryOf = (hash, message) => ({
|
|
24789
|
-
state: message.parsed?.to ?? "",
|
|
24790
|
-
hash,
|
|
24791
|
-
actor: message.parsed?.actor ?? ""
|
|
24792
|
-
});
|
|
24793
|
-
const costEntriesOf = (hash, message) => message.cost.map((c) => ({
|
|
24794
|
-
cost: c.cost,
|
|
24795
|
-
model: c.model ?? "unspecified",
|
|
24796
|
-
hash
|
|
24797
|
-
}));
|
|
24798
|
-
const hashAt = (history, index) => history[index]?.hash;
|
|
24799
|
-
const historyFormatFault = (history, location) => formatFault(history.slice(location.baseIndex + 1).concat(history[location.baseIndex]?.message.startsWith("gtd") ? [history[location.baseIndex]] : []).map((c) => ({
|
|
24800
|
-
hash: c.hash,
|
|
24801
|
-
format: parseCommitMessage(c.message).format
|
|
24802
|
-
})));
|
|
24803
|
-
/** Fails on a history in a format this gtd does not read, before anything acts on it. */
|
|
24804
|
-
const runOf = (history, location, closingHash = void 0) => {
|
|
24805
|
-
const fault = historyFormatFault(history, location);
|
|
24806
|
-
return fault === void 0 ? succeed$1(buildRun(history, location, closingHash)) : fail$4(new Error(fault));
|
|
24807
24719
|
};
|
|
24808
|
-
const
|
|
24809
|
-
|
|
24810
|
-
|
|
24811
|
-
|
|
24812
|
-
|
|
24813
|
-
|
|
24814
|
-
|
|
24720
|
+
const renderGitWrite = (write) => {
|
|
24721
|
+
switch (write.kind) {
|
|
24722
|
+
case "commitAll": return commitAll(write.message);
|
|
24723
|
+
case "commitPath": return commitPath(write.path, write.message);
|
|
24724
|
+
case "commitClean": return commitClean(write.message);
|
|
24725
|
+
case "updateRef": return updateRef(write.ref, write.target);
|
|
24726
|
+
case "keepTurn": return keepTurn(write.ref, write.message);
|
|
24815
24727
|
}
|
|
24816
24728
|
};
|
|
24817
|
-
const approvalEntryOf = (hash, message) => message.approved === void 0 ? [] : [{
|
|
24818
|
-
step: formatStepId(message.approved.step),
|
|
24819
|
-
name: message.approved.name,
|
|
24820
|
-
email: message.approved.email,
|
|
24821
|
-
commit: hash
|
|
24822
|
-
}];
|
|
24823
|
-
const buildRun = (history, location, closingHash) => {
|
|
24824
|
-
const processCommits = history.slice(location.processStart);
|
|
24825
|
-
const parsed = processCommits.map((c) => parseCommitMessage(c.message));
|
|
24826
|
-
const first = parsed[0];
|
|
24827
|
-
const startParentHash = hashAt(history, location.processStart - 1) ?? EMPTY_TREE$1;
|
|
24828
|
-
const head = history[history.length - 1];
|
|
24829
|
-
return {
|
|
24830
|
-
workflow: location.workflow,
|
|
24831
|
-
legacyOpening: location.legacyOpening,
|
|
24832
|
-
startHash: hashAt(history, location.processStart) ?? head?.hash ?? EMPTY_TREE$1,
|
|
24833
|
-
startParentHash,
|
|
24834
|
-
diffBase: first?.reviewBase ?? startParentHash,
|
|
24835
|
-
trace: processCommits.map((c, i) => traceEntryOf(c.hash, parsed[i])),
|
|
24836
|
-
costEntries: parsed.flatMap((m, i) => costEntriesOf(processCommits[i].hash, m)),
|
|
24837
|
-
judgeVerdicts: parsed.flatMap((m) => m.judge),
|
|
24838
|
-
marks: parsed.flatMap((m) => m.marks),
|
|
24839
|
-
approvals: processCommits.flatMap((c, i) => approvalEntryOf(c.hash, parsed[i])),
|
|
24840
|
-
pinnedVars: { ...first?.vars },
|
|
24841
|
-
pinnedConfig: pinnedConfigOf(history.slice(Math.max(location.baseIndex, 0))),
|
|
24842
|
-
headTurn: headTurnOf(head),
|
|
24843
|
-
closingHash,
|
|
24844
|
-
episode: {
|
|
24845
|
-
base: hashAt(history, location.baseIndex),
|
|
24846
|
-
commits: history.slice(location.baseIndex + 1)
|
|
24847
|
-
}
|
|
24848
|
-
};
|
|
24849
|
-
};
|
|
24850
|
-
const PAGE_TIERS = [
|
|
24851
|
-
32,
|
|
24852
|
-
128,
|
|
24853
|
-
512,
|
|
24854
|
-
Infinity
|
|
24855
|
-
];
|
|
24856
24729
|
/**
|
|
24857
|
-
* `
|
|
24858
|
-
*
|
|
24859
|
-
*
|
|
24860
|
-
*
|
|
24861
|
-
* `locateEpisode`'s own `opening` branch (a started episode's first commit),
|
|
24862
|
-
* which takes priority there over the initial-state check no matter what the
|
|
24863
|
-
* workflow's first step is named — including one named after `def.initial`
|
|
24864
|
-
* itself, where the two branches would otherwise disagree. A bare human commit can
|
|
24865
|
-
* also be a self-loop step landing rather than a real opening (this
|
|
24866
|
-
* subject-only pass can't read the `Gtd-Step` trailer that would tell them
|
|
24867
|
-
* apart), so ruling it out here can only make `findBoundaryBase` walk a FEW
|
|
24868
|
-
* commits further back than strictly needed — never stop short of the
|
|
24869
|
-
* boundary `locateEpisode` would find once it has full commit bodies in
|
|
24870
|
-
* hand. That asymmetry is the only direction this approximation may err in;
|
|
24871
|
-
* it must never call a real boundary "not yet found".
|
|
24730
|
+
* Wraps `command` so a non-zero exit prints `prompt` plus the command's
|
|
24731
|
+
* captured output before propagating that exit code — mirrors `Emit.ts`'s
|
|
24732
|
+
* `failurePromptWrapper` (same reverse-import concern as `renderOutcome`
|
|
24733
|
+
* above: `Emit.ts` imports `shellQuote` from here already).
|
|
24872
24734
|
*/
|
|
24873
|
-
const
|
|
24874
|
-
|
|
24875
|
-
|
|
24876
|
-
|
|
24877
|
-
|
|
24735
|
+
const failurePromptWrapper$1 = (command, prompt) => {
|
|
24736
|
+
return [
|
|
24737
|
+
`gtd_validate_status=0`,
|
|
24738
|
+
`gtd_validate_out="$( {`,
|
|
24739
|
+
command,
|
|
24740
|
+
`} 2>&1 )" || gtd_validate_status=$?`,
|
|
24741
|
+
`if [ "$gtd_validate_status" -ne 0 ]; then`,
|
|
24742
|
+
` printf '%s\\n\\n%s\\n' ${shellQuote(prompt)} "$gtd_validate_out"`,
|
|
24743
|
+
` exit "$gtd_validate_status"`,
|
|
24744
|
+
`fi`
|
|
24745
|
+
].join("\n");
|
|
24878
24746
|
};
|
|
24879
|
-
|
|
24880
|
-
|
|
24881
|
-
|
|
24747
|
+
const renderLandStep = (step) => {
|
|
24748
|
+
if (step.kind === "gitWrite") return renderGitWrite(step.write);
|
|
24749
|
+
if (step.kind === "outcome") return renderOutcome(step.outcome);
|
|
24750
|
+
if (step.kind === "uncheck") return `gtd uncheck ${shellQuote(step.file)}`;
|
|
24751
|
+
return step.onFailure !== void 0 ? failurePromptWrapper$1(step.command, step.onFailure) : step.command;
|
|
24882
24752
|
};
|
|
24753
|
+
const ScriptSurface = { render: (steps) => {
|
|
24754
|
+
const rendered = steps.map(renderLandStep);
|
|
24755
|
+
return rendered.length === 0 ? "" : ["set -eu", ...rendered].join("\n\n");
|
|
24756
|
+
} };
|
|
24757
|
+
//#endregion
|
|
24758
|
+
//#region src/emit/Emit.ts
|
|
24759
|
+
/** Exits 0 when the declared steering file is absent — nothing to format or validate yet. An OR list, not `if`: its left side failing never trips `set -e`. */
|
|
24760
|
+
const fileExistsGuard = (file) => `[ -f ${shellQuote(file)} ] || exit 0`;
|
|
24761
|
+
/** A mode or `ui.format` command finds the file it works on in `$GTD_FILE`. */
|
|
24762
|
+
const withFileVar = (command, file) => `export GTD_FILE=${shellQuote(file)}\n${command}`;
|
|
24763
|
+
const SIMPLE_LEADING_WORD_RE = /^[A-Za-z0-9_./-]+(?=[ \t]|$)/;
|
|
24764
|
+
const SHELL_METACHARACTER_RE = /[|&;$<>(){}`]/;
|
|
24765
|
+
const FILE_VAR_RE = /"\$GTD_FILE"|"\$\{GTD_FILE\}"|\$GTD_FILE\b|\$\{GTD_FILE\}/g;
|
|
24766
|
+
/** `command` with `file` written in for `$GTD_FILE` — one line an agent can copy, equivalent to running it under `withFileVar`. */
|
|
24767
|
+
const commandForFile = (command, file) => command.replace(FILE_VAR_RE, shellQuote(file));
|
|
24883
24768
|
/**
|
|
24884
|
-
*
|
|
24885
|
-
*
|
|
24886
|
-
*
|
|
24887
|
-
*
|
|
24888
|
-
* the boundary commit itself: `commitHistory`'s range excludes `base`, and
|
|
24889
|
-
* `runOf` needs the boundary commit present in the read history
|
|
24890
|
-
* (`episode.base`, `startParentHash`). `base: undefined` means "read from the
|
|
24891
|
-
* repository root" — an empty repository, an unresolvable `head`, or an
|
|
24892
|
-
* episode that reaches the root commit, none of which cost anything extra
|
|
24893
|
-
* since that IS the whole history in those cases.
|
|
24894
|
-
*
|
|
24895
|
-
* `head` is the CONCRETE hash the literal `head` argument resolved to on this
|
|
24896
|
-
* call's first read — `undefined` only when it never resolved (an empty
|
|
24897
|
-
* repository, or an unresolvable ref). The caller must hand this hash, not
|
|
24898
|
-
* the original `head` argument, to its own `commitHistory` call: resolving a
|
|
24899
|
-
* symbolic ref (literal `HEAD`, most callers' default) a second time risks
|
|
24900
|
-
* reading a DIFFERENT commit than this function paged through, if something
|
|
24901
|
-
* moves HEAD in between — pinning both reads to one resolved hash is what
|
|
24902
|
-
* keeps the two-read split from disagreeing with itself.
|
|
24769
|
+
* The leading word of a rendered `format:`/`validate:` command, when the
|
|
24770
|
+
* command has exactly one unambiguous binary to probe — `undefined` for
|
|
24771
|
+
* anything a shell metacharacter could hide a second command behind. A missing
|
|
24772
|
+
* guard degrades to a raw exit; a wrong guard would refuse a working command.
|
|
24903
24773
|
*/
|
|
24904
|
-
const
|
|
24905
|
-
|
|
24906
|
-
|
|
24907
|
-
|
|
24908
|
-
|
|
24909
|
-
const wholeHistory = !Number.isFinite(pageSize) || page.length < pageSize;
|
|
24910
|
-
const boundaryIdx = boundaryIndexIn(def, page);
|
|
24911
|
-
if (boundaryIdx === void 0 || boundaryIdx === 0) {
|
|
24912
|
-
if (wholeHistory) return {
|
|
24913
|
-
base: void 0,
|
|
24914
|
-
head: pinnedHead
|
|
24915
|
-
};
|
|
24916
|
-
continue;
|
|
24917
|
-
}
|
|
24918
|
-
return {
|
|
24919
|
-
base: page[boundaryIdx - 1].hash,
|
|
24920
|
-
head: pinnedHead
|
|
24921
|
-
};
|
|
24922
|
-
}
|
|
24923
|
-
return {
|
|
24924
|
-
base: void 0,
|
|
24925
|
-
head: pinnedHead
|
|
24926
|
-
};
|
|
24927
|
-
});
|
|
24928
|
-
const historyUpTo = (git, def, head) => flatMap(findBoundaryBase(git, def, head), ({ base, head: resolvedHead }) => git.commitHistory(base, resolvedHead));
|
|
24929
|
-
const computeProcessRun = (git, def, head) => flatMap(historyUpTo(git, def, head), (history) => runOf(history, locateEpisode(def, history)));
|
|
24930
|
-
const changedSinceStart = (pinned, current, workspace) => {
|
|
24931
|
-
if (pinned === void 0) return [];
|
|
24932
|
-
return changedConfigFiles(pinned.files, current).filter((path) => {
|
|
24933
|
-
const committed = workspace.readCommittedSync(path, pinned.commit);
|
|
24934
|
-
return current[path] === void 0 || committed === void 0 || contentHash(committed) !== current[path];
|
|
24935
|
-
});
|
|
24936
|
-
};
|
|
24937
|
-
const warnConfigChanges = (run, current, workspace) => gen(function* () {
|
|
24938
|
-
const changed = changedSinceStart(run.pinnedConfig, current, workspace);
|
|
24939
|
-
if (changed.length === 0) return;
|
|
24940
|
-
yield* (yield* Narrator).warn(`gtd: warning: the config changed since this process started (${changed.join(", ")}) — the rest of the process replays under the changed config`);
|
|
24941
|
-
});
|
|
24942
|
-
/** The run alone, never replaying — `gtd abandon` must work even when replay would refuse, short of a history it cannot read. */
|
|
24943
|
-
const currentRun = gen(function* () {
|
|
24944
|
-
const git = yield* GitService;
|
|
24945
|
-
const config = yield* (yield* ConfigService).load;
|
|
24946
|
-
return yield* computeProcessRun(git, config.workflow);
|
|
24947
|
-
});
|
|
24948
|
-
const runAtTip = (git, def, tip) => gen(function* () {
|
|
24949
|
-
if (tip === void 0) {
|
|
24950
|
-
const history = yield* historyUpTo(git, def, void 0);
|
|
24951
|
-
return yield* runOf(history, locateEpisode(def, history));
|
|
24952
|
-
}
|
|
24953
|
-
if (!(parseCommitMessage(tip.subject).parsed?.to === def.initial)) {
|
|
24954
|
-
const history = yield* historyUpTo(git, def, tip.hash);
|
|
24955
|
-
return yield* runOf(history, locateEpisode(def, history));
|
|
24956
|
-
}
|
|
24957
|
-
const parentRef = `${tip.hash}~1`;
|
|
24958
|
-
const before = yield* historyUpTo(git, def, parentRef);
|
|
24959
|
-
const location = locateEpisode(def, before);
|
|
24960
|
-
let closing = yield* git.commitHistory(parentRef, tip.hash);
|
|
24961
|
-
if (closing.length === 0) closing = yield* git.commitHistory(void 0, tip.hash);
|
|
24962
|
-
return yield* runOf([...before, ...closing], { ...location }, tip.hash);
|
|
24963
|
-
});
|
|
24964
|
-
const runAtHead = (git, def) => gen(function* () {
|
|
24965
|
-
return yield* runAtTip(git, def, (yield* git.subjectHistory(1, 0))[0]);
|
|
24966
|
-
});
|
|
24967
|
-
/** The process HEAD closes or sits inside — `gtd summary`'s run. */
|
|
24968
|
-
const summaryRun = gen(function* () {
|
|
24969
|
-
const git = yield* GitService;
|
|
24970
|
-
const def = (yield* (yield* ConfigService).load).workflow;
|
|
24971
|
-
return yield* runAtHead(git, def);
|
|
24972
|
-
});
|
|
24973
|
-
const commitTree = (workspace, hash) => {
|
|
24974
|
-
let entries;
|
|
24975
|
-
const list = () => entries ??= workspace.treeSync(hash);
|
|
24976
|
-
let sortedPaths;
|
|
24977
|
-
const contents = /* @__PURE__ */ new Map();
|
|
24978
|
-
return {
|
|
24979
|
-
paths: () => sortedPaths ??= [...list().keys()].sort(),
|
|
24980
|
-
read: (path) => {
|
|
24981
|
-
if (!list().has(path)) return void 0;
|
|
24982
|
-
if (!contents.has(path)) contents.set(path, workspace.readCommittedSync(path, hash));
|
|
24983
|
-
return contents.get(path);
|
|
24984
|
-
},
|
|
24985
|
-
id: (path) => list().get(path)
|
|
24986
|
-
};
|
|
24774
|
+
const extractLeadingBinary = (command) => {
|
|
24775
|
+
if (command.includes("\n")) return void 0;
|
|
24776
|
+
if (SHELL_METACHARACTER_RE.test(command.replace(FILE_VAR_RE, ""))) return void 0;
|
|
24777
|
+
const match = SIMPLE_LEADING_WORD_RE.exec(command);
|
|
24778
|
+
return match === null ? void 0 : match[0];
|
|
24987
24779
|
};
|
|
24988
24780
|
/**
|
|
24989
|
-
*
|
|
24990
|
-
*
|
|
24991
|
-
*
|
|
24992
|
-
* field's own comment for why the memo lives there and not on `Workspace`.
|
|
24781
|
+
* Names a typo'd or uninstalled binary and the `$PATH` it was looked up on.
|
|
24782
|
+
* `$PATH` is a shell word the DRIVER's shell expands at run time, so gtd never
|
|
24783
|
+
* resolves it in-process. Exits 127, matching bash's "command not found".
|
|
24993
24784
|
*/
|
|
24994
|
-
const
|
|
24995
|
-
const
|
|
24996
|
-
|
|
24997
|
-
|
|
24998
|
-
|
|
24999
|
-
|
|
25000
|
-
|
|
25001
|
-
|
|
25002
|
-
};
|
|
25003
|
-
return {
|
|
25004
|
-
paths: () => paths,
|
|
25005
|
-
read: (path) => {
|
|
25006
|
-
if (!entries.has(path)) return void 0;
|
|
25007
|
-
const content = readWorktree(path);
|
|
25008
|
-
return content === void 0 || rewrite?.path !== path ? content : rewrite.apply(content);
|
|
25009
|
-
},
|
|
25010
|
-
id: (path) => rewrite?.path === path ? void 0 : entries.get(path)
|
|
25011
|
-
};
|
|
24785
|
+
const binaryGuard = (binary, mode, key) => {
|
|
24786
|
+
const messageQ = shellQuote(`gtd: mode "${mode}": "${key}" command not found: ${binary}`);
|
|
24787
|
+
return [
|
|
24788
|
+
`command -v ${shellQuote(binary)} >/dev/null 2>&1 || {`,
|
|
24789
|
+
` printf '%s\\n%s\\n' ${messageQ} '$PATH: '"$PATH"`,
|
|
24790
|
+
` exit 127`,
|
|
24791
|
+
`}`
|
|
24792
|
+
].join("\n");
|
|
25012
24793
|
};
|
|
25013
24794
|
/**
|
|
25014
|
-
*
|
|
25015
|
-
*
|
|
25016
|
-
*
|
|
25017
|
-
* are always live. `startOverrides` are `--var` values, passed only while a
|
|
25018
|
-
* process is being started.
|
|
24795
|
+
* Prints `prompt` plus the captured output on a non-zero exit, then propagates
|
|
24796
|
+
* it. The `{ … }` group inlines a multi-line `command` verbatim; the
|
|
24797
|
+
* assignment sits left of `||` so `set -e` never trips on the failure itself.
|
|
25019
24798
|
*/
|
|
25020
|
-
const
|
|
25021
|
-
|
|
25022
|
-
|
|
25023
|
-
|
|
25024
|
-
|
|
25025
|
-
|
|
25026
|
-
|
|
25027
|
-
|
|
25028
|
-
|
|
25029
|
-
|
|
25030
|
-
|
|
25031
|
-
const named = config.workflowNamed(run.workflow);
|
|
25032
|
-
if (named !== void 0) return succeed$1(named);
|
|
25033
|
-
return fail$4(/* @__PURE__ */ new Error(`gtd: this process runs workflow "${run.workflow}", which gtd.config.ts no longer defines — run \`gtd abandon\` to start over`));
|
|
24799
|
+
const failurePromptWrapper = (command, prompt) => {
|
|
24800
|
+
return [
|
|
24801
|
+
`gtd_validate_status=0`,
|
|
24802
|
+
`gtd_validate_out="$( {`,
|
|
24803
|
+
command,
|
|
24804
|
+
`} 2>&1 )" || gtd_validate_status=$?`,
|
|
24805
|
+
`if [ "$gtd_validate_status" -ne 0 ]; then`,
|
|
24806
|
+
` printf '%s\\n\\n%s\\n' ${shellQuote(prompt)} "$gtd_validate_out"`,
|
|
24807
|
+
` exit "$gtd_validate_status"`,
|
|
24808
|
+
`fi`
|
|
24809
|
+
].join("\n");
|
|
25034
24810
|
};
|
|
25035
|
-
|
|
25036
|
-
|
|
25037
|
-
|
|
25038
|
-
* anyway. Absent falls back to a conservative default.
|
|
25039
|
-
*/
|
|
25040
|
-
const DEFAULT_JUDGE_BUDGET_BYTES = 32768;
|
|
25041
|
-
const judgeBudgetBytes = (vars) => {
|
|
25042
|
-
const raw = vars.judgeBudgetBytes;
|
|
25043
|
-
if (raw === void 0) return DEFAULT_JUDGE_BUDGET_BYTES;
|
|
25044
|
-
const n = Number(raw);
|
|
25045
|
-
if (raw.trim() === "" || !Number.isInteger(n) || n <= 0) throw new Error(`"judgeBudgetBytes" must be a positive integer — got ${JSON.stringify(raw)} (blanking this var disables the payload bound rather than the mechanism it guards, so it is refused rather than defaulted)`);
|
|
25046
|
-
return n;
|
|
24811
|
+
const renderStep = (step) => {
|
|
24812
|
+
if (step.kind === "command" && step.onFailure !== void 0) return failurePromptWrapper(step.command, step.onFailure);
|
|
24813
|
+
return step.command;
|
|
25047
24814
|
};
|
|
25048
|
-
const
|
|
25049
|
-
|
|
25050
|
-
|
|
25051
|
-
|
|
25052
|
-
|
|
25053
|
-
},
|
|
25054
|
-
vars: setup.vars,
|
|
25055
|
-
env: setup.env,
|
|
25056
|
-
start: setup.run.diffBase,
|
|
25057
|
-
startTree: setup.run.diffBase === EMPTY_TREE$1 ? treeFromRecord({}) : commitTree(setup.workspace, setup.run.diffBase),
|
|
25058
|
-
budgetBytes: setup.budget,
|
|
25059
|
-
skills: setup.skills,
|
|
25060
|
-
configuredSkills: setup.def.configuredSkills,
|
|
25061
|
-
access: setup.access,
|
|
25062
|
-
configuredAccess: setup.def.configuredAccess,
|
|
25063
|
-
...pending !== void 0 ? { pending } : {}
|
|
24815
|
+
const assembleScript = (steps) => steps.length === 0 ? "" : ["set -eu", ...steps.map(renderStep)].join("\n\n");
|
|
24816
|
+
/** Build both halves from already-rendered command strings; an empty (or omitted) array produces the empty-string result, so a driver checking `if [ -n "$required" ]` never has to special-case a bare preamble. */
|
|
24817
|
+
const emitScripts = (required = [], optional = []) => ({
|
|
24818
|
+
required: assembleScript(required),
|
|
24819
|
+
optional: assembleScript(optional)
|
|
25064
24820
|
});
|
|
25065
|
-
|
|
25066
|
-
|
|
25067
|
-
|
|
25068
|
-
|
|
25069
|
-
};
|
|
25070
|
-
const judgeDocument = (step) => step.request.kind === "judge" ? judgeJson(step.request.evidence, step.request.questions) : void 0;
|
|
24821
|
+
/** Exported so `src/twins/EmittedScriptRecognizer.ts` can recognize these by exact string comparison. `printf`, not `echo`, avoids shell-specific backslash-escape quirks. */
|
|
24822
|
+
const DID_NOT_RUN_COMMENT = "# gtd emitted this and did NOT run it — run it with `sh` to apply it";
|
|
24823
|
+
const PRESENTATION_ONLY_COMMENT = "# presentation only — safe to skip";
|
|
24824
|
+
const PRESENTATION_FAILURE_WARNING = "printf 'gtd: presentation-only follow-up failed — continuing\\n' >&2";
|
|
25071
24825
|
/**
|
|
25072
|
-
*
|
|
25073
|
-
*
|
|
25074
|
-
*
|
|
24826
|
+
* A write command's single pasteable script (`gtd abandon | sh`,
|
|
24827
|
+
* `gtd land --json=script`'s own field). `optional`, when non-empty, is
|
|
24828
|
+
* wrapped in a subshell whose failure is swallowed — presentation-only, so it
|
|
24829
|
+
* must never turn a landed turn into a non-zero exit.
|
|
25075
24830
|
*/
|
|
25076
|
-
const
|
|
25077
|
-
|
|
25078
|
-
|
|
25079
|
-
|
|
25080
|
-
|
|
25081
|
-
|
|
25082
|
-
|
|
25083
|
-
|
|
25084
|
-
|
|
25085
|
-
|
|
25086
|
-
|
|
24831
|
+
const combinedScript = (required, optional) => {
|
|
24832
|
+
if (required.length === 0) return "";
|
|
24833
|
+
if (optional.length === 0) return `${DID_NOT_RUN_COMMENT}\n\n${required}`;
|
|
24834
|
+
return [
|
|
24835
|
+
DID_NOT_RUN_COMMENT,
|
|
24836
|
+
"",
|
|
24837
|
+
required,
|
|
24838
|
+
"",
|
|
24839
|
+
PRESENTATION_ONLY_COMMENT,
|
|
24840
|
+
`(\n${optional}\n) || ${PRESENTATION_FAILURE_WARNING}`
|
|
24841
|
+
].join("\n");
|
|
25087
24842
|
};
|
|
25088
|
-
|
|
25089
|
-
|
|
25090
|
-
|
|
25091
|
-
|
|
25092
|
-
|
|
25093
|
-
|
|
25094
|
-
|
|
25095
|
-
|
|
25096
|
-
|
|
25097
|
-
|
|
25098
|
-
|
|
25099
|
-
|
|
25100
|
-
|
|
25101
|
-
|
|
25102
|
-
|
|
25103
|
-
|
|
25104
|
-
|
|
25105
|
-
|
|
25106
|
-
|
|
25107
|
-
|
|
25108
|
-
|
|
25109
|
-
|
|
25110
|
-
|
|
25111
|
-
|
|
24843
|
+
//#endregion
|
|
24844
|
+
//#region src/emit/OutcomeScript.ts
|
|
24845
|
+
/**
|
|
24846
|
+
* A `printf '<fmt>' <args...>` bash statement — `args` are already-valid bash
|
|
24847
|
+
* tokens (a `shellQuote`d literal, a `"$(git …)"` substitution), never
|
|
24848
|
+
* re-quoted here: a value reaches the script as a printf ARGUMENT, never
|
|
24849
|
+
* interpolated into the format itself, so a subject or state name containing
|
|
24850
|
+
* `%` can never be read as a conversion spec. Markers like `->` are arguments
|
|
24851
|
+
* too, keeping every format string free of a leading `-` that a shell's
|
|
24852
|
+
* `printf` could mistake for an option. A real newline in `fmt` is emitted as
|
|
24853
|
+
* the two-character `\n` escape `printf` itself expands, keeping every emitted
|
|
24854
|
+
* statement on one line.
|
|
24855
|
+
*/
|
|
24856
|
+
const printfLine = (fmt, args) => `${OUTCOME_MARKER}\nprintf ${shellQuote(fmt.replace(/\n/g, "\\n"))} ${args.join(" ")}`;
|
|
24857
|
+
/**
|
|
24858
|
+
* Marks an emitted block as print-only. Load-bearing beyond documentation:
|
|
24859
|
+
* `src/twins/EmittedScriptRecognizer.ts` recognizes an outcome block by
|
|
24860
|
+
* this line, and a bare `printf` would be ambiguous with a workflow
|
|
24861
|
+
* `script:` command that happens to start the same way.
|
|
24862
|
+
*/
|
|
24863
|
+
const OUTCOME_MARKER = "# gtd: outcome (print-only)";
|
|
24864
|
+
const FMT_ABANDON_NOOP = "no gtd process is underway (resting at \"%s\") — nothing to abandon\n";
|
|
24865
|
+
const FMT_ABANDONED = "abandoned the process resting at \"%s\" — HEAD is back at %s (\"%s\"), resting at \"%s\".\nEverything the process produced is kept as uncommitted changes (`git status`); discard them with `git checkout -- . && git clean -fd .gtd` for a clean tree.\n";
|
|
24866
|
+
const FMT_RESTORED = "restored the retained history — HEAD is back at %s (\"%s\"), resting at \"%s\". Resume with the loop, or `git reset` to any earlier turn to restart from there.\n";
|
|
24867
|
+
/** `no gtd process is underway (resting at "<initial>") — nothing to abandon` — `gtd abandon`'s no-op plain-text line. */
|
|
24868
|
+
const abandonNoopText = (initial) => renderFormat(FMT_ABANDON_NOOP, initial);
|
|
24869
|
+
/** One already-rendered plain line. */
|
|
24870
|
+
const noteOutcome = (text) => printfLine("%s\n", [shellQuote(text)]);
|
|
24871
|
+
/**
|
|
24872
|
+
* Resolves the post-hoc short hash/subject from `head` (a commitish) in-script
|
|
24873
|
+
* — this line runs AFTER the reset that made `head` the new HEAD, so neither
|
|
24874
|
+
* value is knowable when gtd generates it.
|
|
24875
|
+
*/
|
|
24876
|
+
const abandonedOutcome = (from, head, state) => printfLine(FMT_ABANDONED, [
|
|
24877
|
+
shellQuote(from),
|
|
24878
|
+
`"$(git rev-parse --short ${shellQuote(head)})"`,
|
|
24879
|
+
`"$(git log -1 --format=%s ${shellQuote(head)})"`,
|
|
24880
|
+
shellQuote(state)
|
|
24881
|
+
]);
|
|
24882
|
+
/** `gtd abandon`'s no-op outcome — the same wording `abandonNoopText` renders. */
|
|
24883
|
+
const abandonNoopOutcome = (initial) => noteOutcome(abandonNoopText(initial));
|
|
24884
|
+
/** Resolves the post-hoc short hash/subject from `to` (a commitish) in-script, for the same reason `abandonedOutcome` does. */
|
|
24885
|
+
const restoredOutcome = (to, state) => printfLine(FMT_RESTORED, [
|
|
24886
|
+
`"$(git rev-parse --short ${shellQuote(to)})"`,
|
|
24887
|
+
`"$(git log -1 --format=%s ${shellQuote(to)})"`,
|
|
24888
|
+
shellQuote(state)
|
|
24889
|
+
]);
|
|
24890
|
+
//#endregion
|
|
24891
|
+
//#region src/emit/ModeContradiction.ts
|
|
24892
|
+
/**
|
|
24893
|
+
* Written for two readers at once, since gtd validate's exit code carries no
|
|
24894
|
+
* second meaning to distinguish them: the agent (this is a CONFIGURATION BUG,
|
|
24895
|
+
* not the steering file — stop, don't edit it, or a fix-retry loop will
|
|
24896
|
+
* thrash on the document) and the human (which mode, and the exact rendered
|
|
24897
|
+
* `format:` command, to fix `gtd.config.ts` without reproducing the failure).
|
|
24898
|
+
*/
|
|
24899
|
+
const contradictionMessage = (mode, formatCommand) => `gtd: mode "${mode}"'s format: command breaks its own validator — this is a CONFIGURATION BUG, not a problem with the steering file. Do NOT edit the steering file — stop and end your turn now.\nThe rendered format: command that ran:\n${formatCommand}\nWhat it turned mode "${mode}"'s own canonical sample into:`;
|
|
24900
|
+
/**
|
|
24901
|
+
* Formats a copy of the mode's own canonical sample and re-validates it with
|
|
24902
|
+
* `gtd check <mode>`, failing loudly when the round-trip breaks it — detects a
|
|
24903
|
+
* mode whose `format:` contradicts its own `validate:` without ever touching
|
|
24904
|
+
* the real steering file. `printf '%s'` over a `shellQuote`d sample, never a
|
|
24905
|
+
* heredoc, since no delimiter chosen ahead of time can collide with the
|
|
24906
|
+
* sample's own bytes. One bash fragment with no blank lines (so
|
|
24907
|
+
* `src/emit/Emit.ts`'s `assembleScript` never splits it from a sibling step), meant
|
|
24908
|
+
* to run BEFORE `[ -f <file> ] || exit 0` in the emitted validate script so it
|
|
24909
|
+
* still fires at a first-write beat where the real steering file doesn't
|
|
24910
|
+
* exist yet.
|
|
24911
|
+
*/
|
|
24912
|
+
const buildModeContradictionCheck = (inputs) => {
|
|
24913
|
+
const { mode, samplePath, sample, formatCommand } = inputs;
|
|
24914
|
+
const pathQ = shellQuote(samplePath);
|
|
24915
|
+
const messageQ = shellQuote(contradictionMessage(mode, formatCommand));
|
|
24916
|
+
return [
|
|
24917
|
+
`printf '%s' ${shellQuote(sample)} > ${pathQ}`,
|
|
24918
|
+
formatCommand,
|
|
24919
|
+
`gtd check ${mode} ${pathQ} >/dev/null 2>&1 || {`,
|
|
24920
|
+
` printf '%s\\n' ${messageQ} >&2`,
|
|
24921
|
+
` cat ${pathQ} >&2`,
|
|
24922
|
+
` rm -f ${pathQ}`,
|
|
24923
|
+
` exit 1`,
|
|
24924
|
+
`}`,
|
|
24925
|
+
`rm -f ${pathQ}`
|
|
24926
|
+
].join("\n");
|
|
25112
24927
|
};
|
|
25113
|
-
|
|
25114
|
-
|
|
25115
|
-
|
|
25116
|
-
|
|
25117
|
-
|
|
25118
|
-
|
|
25119
|
-
|
|
25120
|
-
|
|
25121
|
-
const request = step.request;
|
|
25122
|
-
const common = commonOf(step);
|
|
25123
|
-
switch (request.kind) {
|
|
25124
|
-
case "agent": return promptDef(common, request, step.skills, step.access);
|
|
25125
|
-
case "run": return scriptDef(common, request);
|
|
25126
|
-
case "judge": return judgeDef(common, request, step);
|
|
25127
|
-
case "human": return messageDef(common, request, step);
|
|
25128
|
-
}
|
|
24928
|
+
/**
|
|
24929
|
+
* Skip notice for a mode whose validator is EXTERNAL (a user `validate:`
|
|
24930
|
+
* command, not one of the two built-in modes) — there's no in-process parser
|
|
24931
|
+
* to round-trip a sample through. Printed to stderr since silence would read
|
|
24932
|
+
* as a clean bill of health.
|
|
24933
|
+
*/
|
|
24934
|
+
const modeContradictionSkipNotice = (mode) => {
|
|
24935
|
+
return `printf '%s\\n' ${shellQuote(`gtd: mode "${mode}" has an external validate: command — skipping the format/validate contradiction check`)} >&2`;
|
|
25129
24936
|
};
|
|
25130
|
-
|
|
24937
|
+
//#endregion
|
|
24938
|
+
//#region src/emit/SteeringMode.ts
|
|
25131
24939
|
/**
|
|
25132
|
-
*
|
|
25133
|
-
*
|
|
25134
|
-
*
|
|
25135
|
-
*
|
|
24940
|
+
* `format` alone (no live/external verdict) when there's no validator at
|
|
24941
|
+
* all; a live in-process validator for a `"builtin"` validate OR a command
|
|
24942
|
+
* that is exactly the format's own seeded string; `externalValidate` for any
|
|
24943
|
+
* other command. Each arm returns directly — a flat sequence, not nested
|
|
24944
|
+
* ternaries — so the four outcomes stay each their own case to read, not a
|
|
24945
|
+
* boolean expression to re-derive.
|
|
25136
24946
|
*/
|
|
25137
|
-
const
|
|
25138
|
-
|
|
25139
|
-
if (
|
|
25140
|
-
|
|
25141
|
-
|
|
24947
|
+
const capabilitiesFor = (mode, format, validate) => {
|
|
24948
|
+
if (format === void 0) return validate?.kind === "command" ? { externalValidate: true } : {};
|
|
24949
|
+
if (validate?.kind === "builtin") return {
|
|
24950
|
+
format,
|
|
24951
|
+
liveValidate: format.validate
|
|
24952
|
+
};
|
|
24953
|
+
if (validate === void 0) return { format };
|
|
24954
|
+
if (isSeededValidateCommand(mode, validate.command)) return {
|
|
24955
|
+
format,
|
|
24956
|
+
liveValidate: format.validate
|
|
25142
24957
|
};
|
|
25143
|
-
const start = scopeRunStart(trace, rest.memoryScope);
|
|
25144
|
-
const token = start <= 0 ? run.startParentHash : trace[start - 1].enteredAt;
|
|
25145
|
-
const resumed = trace.slice(Math.max(start, 0), trace.length - 1).some((s) => s.kind === "agent" && s.memoryScope === rest.memoryScope);
|
|
25146
24958
|
return {
|
|
25147
|
-
|
|
25148
|
-
|
|
24959
|
+
format,
|
|
24960
|
+
externalValidate: true
|
|
25149
24961
|
};
|
|
25150
24962
|
};
|
|
25151
|
-
|
|
25152
|
-
|
|
25153
|
-
|
|
25154
|
-
|
|
25155
|
-
|
|
25156
|
-
|
|
25157
|
-
|
|
25158
|
-
|
|
25159
|
-
|
|
25160
|
-
|
|
25161
|
-
|
|
25162
|
-
|
|
25163
|
-
|
|
25164
|
-
|
|
25165
|
-
|
|
25166
|
-
|
|
25167
|
-
|
|
25168
|
-
|
|
25169
|
-
|
|
25170
|
-
|
|
25171
|
-
|
|
25172
|
-
|
|
25173
|
-
byModel: costByModel(entries)
|
|
25174
|
-
});
|
|
25175
|
-
const setupFor = (config, run, workspace, host, startOverrides = {}) => gen(function* () {
|
|
25176
|
-
const resolved = yield* workflowOf(config, run);
|
|
25177
|
-
const def = resolved.def;
|
|
25178
|
-
const startFlags = run.trace.length === 0 ? yield* StartFlags : {};
|
|
25179
|
-
const { vars, env } = settingsFor(resolved, run.pinnedVars, host.env, startOverrides, startFlags);
|
|
25180
|
-
const skills = yield* resolveScopeSkills(def, vars);
|
|
25181
|
-
const access = yield* resolveScopeAccess(def, skills, vars);
|
|
25182
|
-
const budget = yield* try_({
|
|
25183
|
-
try: () => judgeBudgetBytes(vars),
|
|
25184
|
-
catch: (e) => e instanceof Error ? e : new Error(String(e))
|
|
25185
|
-
});
|
|
25186
|
-
yield* workspace.episodeTrees(run.episode.base, run.episode.commits.map((c) => c.hash));
|
|
25187
|
-
const base = run.episode.base === void 0 ? {
|
|
25188
|
-
hash: EMPTY_TREE$1,
|
|
25189
|
-
tree: treeFromRecord({})
|
|
25190
|
-
} : {
|
|
25191
|
-
hash: run.episode.base,
|
|
25192
|
-
tree: commitTree(workspace, run.episode.base)
|
|
24963
|
+
/**
|
|
24964
|
+
* Resolve a `mode:` name against `def.modes` plus the built-in registry
|
|
24965
|
+
* (`src/workflow/SteeringFormats.ts`) — half by half: a declared `format:`/`validate:`
|
|
24966
|
+
* wins, and an undeclared `validate:` falls back to the built-in format's own
|
|
24967
|
+
* parser when the name is registered. `format` is set from the registry
|
|
24968
|
+
* ALONE, independent of which half of `validate` wins — a declared validator
|
|
24969
|
+
* overrides validation, not the format identity.
|
|
24970
|
+
*
|
|
24971
|
+
* `def: undefined` is the LSP's built-in-only fallback — resolving `mode`
|
|
24972
|
+
* against the registry alone, with no workflow definition in hand (e.g. the
|
|
24973
|
+
* basename dispatch for a `REVIEW.md` no config maps).
|
|
24974
|
+
*
|
|
24975
|
+
* The `"unknown"` arm carries the whole refusal message, naming what IS known
|
|
24976
|
+
* when `def` is given — this is the one place that message is built, so it
|
|
24977
|
+
* can never drift from what actually resolved.
|
|
24978
|
+
*/
|
|
24979
|
+
const resolveMode = (def, state, mode) => {
|
|
24980
|
+
const declared = def?.modes?.[mode];
|
|
24981
|
+
const format = steeringFormatFor(mode);
|
|
24982
|
+
if (declared === void 0 && format === void 0) return {
|
|
24983
|
+
kind: "unknown",
|
|
24984
|
+
message: def !== void 0 ? `state "${state}": mode "${mode}" is not defined by the active workflow (known modes: ${knownModes(def).join(", ")})` : `mode "${mode}" is not a built-in steering format`
|
|
25193
24985
|
};
|
|
25194
|
-
const
|
|
25195
|
-
|
|
25196
|
-
|
|
25197
|
-
|
|
25198
|
-
|
|
25199
|
-
|
|
25200
|
-
|
|
25201
|
-
message: c.message,
|
|
25202
|
-
tree: commitTree(workspace, c.hash),
|
|
25203
|
-
...exists ? { kept: {
|
|
25204
|
-
hash: tip,
|
|
25205
|
-
tree: commitTree(workspace, tip)
|
|
25206
|
-
} } : {}
|
|
25207
|
-
});
|
|
25208
|
-
}
|
|
25209
|
-
let pendingMemo;
|
|
24986
|
+
const validate = declared?.validate !== void 0 ? {
|
|
24987
|
+
kind: "command",
|
|
24988
|
+
command: declared.validate
|
|
24989
|
+
} : format !== void 0 ? {
|
|
24990
|
+
kind: "builtin",
|
|
24991
|
+
format
|
|
24992
|
+
} : void 0;
|
|
25210
24993
|
return {
|
|
25211
|
-
resolved,
|
|
25212
|
-
|
|
25213
|
-
|
|
25214
|
-
|
|
25215
|
-
|
|
25216
|
-
|
|
25217
|
-
run,
|
|
25218
|
-
vars,
|
|
25219
|
-
env,
|
|
25220
|
-
budget,
|
|
25221
|
-
skills,
|
|
25222
|
-
access,
|
|
25223
|
-
workspace,
|
|
25224
|
-
base,
|
|
25225
|
-
episode,
|
|
25226
|
-
pendingWorktree: () => pendingMemo ??= workspace.worktreeSync()
|
|
25227
|
-
}
|
|
25228
|
-
};
|
|
25229
|
-
});
|
|
25230
|
-
const pendingChanges = (git, withIndex) => gen(function* () {
|
|
25231
|
-
const worktree = yield* git.changedPaths();
|
|
25232
|
-
if (!withIndex) return worktree;
|
|
25233
|
-
const seen = new Set(worktree.map((e) => e.path));
|
|
25234
|
-
return [...worktree, ...(yield* git.stagedPaths()).filter((e) => !seen.has(e.path))];
|
|
25235
|
-
});
|
|
25236
|
-
/** Resolve the rest at `ref`, or at HEAD when `ref` is `undefined`. */
|
|
25237
|
-
const restAt = (ref, startOverrides = {}) => gen(function* () {
|
|
25238
|
-
const git = yield* GitService;
|
|
25239
|
-
const config = yield* (yield* ConfigService).load;
|
|
25240
|
-
const workspace = yield* Workspace;
|
|
25241
|
-
const host = yield* Host;
|
|
25242
|
-
const run = yield* computeProcessRun(git, config.workflow, ref);
|
|
25243
|
-
const { resolved, def, vars, env, setup } = yield* setupFor(config, run, workspace, host, startOverrides);
|
|
25244
|
-
const outcome = yield* promise(() => replayFor(setup));
|
|
25245
|
-
const error = replayError(outcome);
|
|
25246
|
-
if (error !== void 0 || outcome.kind !== "rest") return yield* fail$4(error ?? /* @__PURE__ */ new Error("gtd: replay found no rest"));
|
|
25247
|
-
const step = outcome.rest;
|
|
25248
|
-
yield* (yield* Narrator).narrate(`rest resolved: ${step.name} (awaits ${step.actor})`);
|
|
25249
|
-
const stepDef = stepDefOf(step);
|
|
25250
|
-
if (stepDef.mode !== void 0 && def.modes[stepDef.mode] === void 0) return yield* fail$4(/* @__PURE__ */ new Error(`gtd: config: step "${step.name}": mode "${stepDef.mode}" is not a mode this workflow knows (${knownModes(def).join(", ")})`));
|
|
25251
|
-
const changes = ref === void 0 ? (yield* pendingChanges(git, stepDef.approve === true)).map((e) => ({
|
|
25252
|
-
status: normalizeStatus(e.status),
|
|
25253
|
-
path: e.path
|
|
25254
|
-
})) : [];
|
|
25255
|
-
const memory = memoryOf(outcome.trace, run);
|
|
25256
|
-
const configNow = config.fingerprint;
|
|
25257
|
-
if (ref === void 0) yield* warnConfigChanges(run, configNow, workspace);
|
|
25258
|
-
return {
|
|
25259
|
-
def,
|
|
25260
|
-
workflowName: resolved.name,
|
|
25261
|
-
state: step.name,
|
|
25262
|
-
stepDef,
|
|
25263
|
-
actor: step.actor,
|
|
25264
|
-
step,
|
|
25265
|
-
trace: outcome.trace,
|
|
25266
|
-
run,
|
|
25267
|
-
vars,
|
|
25268
|
-
env,
|
|
25269
|
-
config: configNow,
|
|
25270
|
-
changes,
|
|
25271
|
-
memory: memory.key,
|
|
25272
|
-
memoryResumed: memory.resumed,
|
|
25273
|
-
hints: hintsOf(stepDef),
|
|
25274
|
-
cost: costTotals(run.costEntries),
|
|
25275
|
-
setup
|
|
24994
|
+
kind: "resolved",
|
|
24995
|
+
mode,
|
|
24996
|
+
...format !== void 0 ? { format } : {},
|
|
24997
|
+
...declared?.format !== void 0 ? { formatCommand: declared.format } : {},
|
|
24998
|
+
...validate !== void 0 ? { validate } : {},
|
|
24999
|
+
capabilities: capabilitiesFor(mode, format, validate)
|
|
25276
25000
|
};
|
|
25001
|
+
};
|
|
25002
|
+
/** `gtd next`'s fix-retry instruction, printed ahead of the failing command's captured output. */
|
|
25003
|
+
const fixPromptInstruction = (file) => `Your last turn does not pass its own validation script. Fix these format violations in ${file}, then finish:`;
|
|
25004
|
+
/**
|
|
25005
|
+
* Pushes a `binaryGuard` step ahead of `command` when its own leading word
|
|
25006
|
+
* is unambiguous — BEFORE, not wrapped around: a guard that fails must stop
|
|
25007
|
+
* the script on its own `exit 127`, without touching `failurePromptWrapper`'s
|
|
25008
|
+
* captured-output path. Each command guards on its OWN leading word — a
|
|
25009
|
+
* guard naming the wrong binary is worse than none.
|
|
25010
|
+
*/
|
|
25011
|
+
const pushGuardedCommand = (steps, mode, key, command, file, onFailure) => {
|
|
25012
|
+
const binary = extractLeadingBinary(command);
|
|
25013
|
+
if (binary !== void 0) steps.push({
|
|
25014
|
+
kind: "command",
|
|
25015
|
+
command: binaryGuard(binary, mode, key)
|
|
25016
|
+
});
|
|
25017
|
+
steps.push({
|
|
25018
|
+
kind: "command",
|
|
25019
|
+
command: withFileVar(command, file),
|
|
25020
|
+
...onFailure !== void 0 ? { onFailure } : {}
|
|
25021
|
+
});
|
|
25022
|
+
};
|
|
25023
|
+
/**
|
|
25024
|
+
* `resolved`'s `format:`/`validate:` as `EmitStep[]` — format first,
|
|
25025
|
+
* validate last, the last one wrapped with `onFailure` when the validator is
|
|
25026
|
+
* a command (a built-in's own in-process parser has no shell step to wrap:
|
|
25027
|
+
* `gtd check`'s seeded command carries that instead). Each command gets its
|
|
25028
|
+
* own `binaryGuard` immediately ahead of it.
|
|
25029
|
+
*/
|
|
25030
|
+
const modeCommandSteps = (resolved, file) => {
|
|
25031
|
+
const steps = [];
|
|
25032
|
+
if (resolved.formatCommand !== void 0) pushGuardedCommand(steps, resolved.mode, "format", resolved.formatCommand, file, void 0);
|
|
25033
|
+
if (resolved.validate?.kind === "command") pushGuardedCommand(steps, resolved.mode, "validate", resolved.validate.command, file, fixPromptInstruction(file));
|
|
25034
|
+
return steps;
|
|
25035
|
+
};
|
|
25036
|
+
/** `<Host.scratchDir>/gtd-mode-sample-<mode>-<pid>.md` — an absolute literal baked in at emit time, never a shell variable. `<pid>` avoids collisions between concurrent `gtd` processes. Never a `/tmp` literal or `mktemp` (`tests/tooling/no-tmp-assumption.test.ts` scans for both). */
|
|
25037
|
+
const scratchSamplePath = (scratchDir, mode) => join(scratchDir, `gtd-mode-sample-${mode}-${process.pid}.md`);
|
|
25038
|
+
/**
|
|
25039
|
+
* The contradiction round-trip/skip-notice steps for `resolved`'s `mode:`.
|
|
25040
|
+
* No `formatCommand` means nothing to round-trip: empty. Otherwise: a live
|
|
25041
|
+
* built-in validator runs the round-trip against that format's own canonical
|
|
25042
|
+
* sample; an external validator prints a one-line skip notice instead, since
|
|
25043
|
+
* silence there would read as a clean bill of health; a format-only mode with
|
|
25044
|
+
* neither has nothing to round-trip either.
|
|
25045
|
+
*/
|
|
25046
|
+
const modeContradictionSteps = (resolved) => gen(function* () {
|
|
25047
|
+
if (resolved.formatCommand === void 0) return [];
|
|
25048
|
+
const { capabilities } = resolved;
|
|
25049
|
+
if (capabilities.format !== void 0 && capabilities.liveValidate !== void 0) {
|
|
25050
|
+
const { scratchDir } = yield* Host;
|
|
25051
|
+
const samplePath = scratchSamplePath(scratchDir, resolved.mode);
|
|
25052
|
+
const formatCommand = withFileVar(resolved.formatCommand, samplePath);
|
|
25053
|
+
return [{
|
|
25054
|
+
kind: "command",
|
|
25055
|
+
command: buildModeContradictionCheck({
|
|
25056
|
+
mode: resolved.mode,
|
|
25057
|
+
samplePath,
|
|
25058
|
+
sample: capabilities.format.sample,
|
|
25059
|
+
formatCommand
|
|
25060
|
+
})
|
|
25061
|
+
}];
|
|
25062
|
+
}
|
|
25063
|
+
if (capabilities.externalValidate === true) return [{
|
|
25064
|
+
kind: "command",
|
|
25065
|
+
command: modeContradictionSkipNotice(resolved.mode)
|
|
25066
|
+
}];
|
|
25067
|
+
return [];
|
|
25277
25068
|
});
|
|
25278
|
-
const currentRest = restAt(void 0);
|
|
25279
25069
|
/**
|
|
25280
|
-
*
|
|
25281
|
-
*
|
|
25282
|
-
*
|
|
25070
|
+
* Renders `resolved`'s complete validate script for `file`: the mode's
|
|
25071
|
+
* format/validate contradiction check, a guard for a not-yet-written file,
|
|
25072
|
+
* then the format/validate commands themselves (format first). gtd never
|
|
25073
|
+
* runs this script itself — `resolveMode`+`validateScriptFor` only render
|
|
25074
|
+
* text for a driver to execute (`gtd renders; the driver executes`).
|
|
25283
25075
|
*/
|
|
25284
|
-
const
|
|
25285
|
-
|
|
25286
|
-
|
|
25287
|
-
|
|
25288
|
-
|
|
25289
|
-
|
|
25290
|
-
const outcome = yield* promise(() => replay({
|
|
25291
|
-
flow: resolved.def.flow,
|
|
25292
|
-
episode: {
|
|
25293
|
-
base: {
|
|
25294
|
-
hash: "",
|
|
25295
|
-
tree: pendingTree(workspace, rest.setup.pendingWorktree(), void 0)
|
|
25296
|
-
},
|
|
25297
|
-
commits: []
|
|
25076
|
+
const validateScriptFor = (resolved, file) => gen(function* () {
|
|
25077
|
+
return { script: emitScripts([
|
|
25078
|
+
...yield* modeContradictionSteps(resolved),
|
|
25079
|
+
{
|
|
25080
|
+
kind: "command",
|
|
25081
|
+
command: fileExistsGuard(file)
|
|
25298
25082
|
},
|
|
25299
|
-
|
|
25300
|
-
|
|
25301
|
-
start: "",
|
|
25302
|
-
budgetBytes: rest.setup.budget,
|
|
25303
|
-
skills,
|
|
25304
|
-
configuredSkills: resolved.def.configuredSkills,
|
|
25305
|
-
access,
|
|
25306
|
-
configuredAccess: resolved.def.configuredAccess
|
|
25307
|
-
}));
|
|
25308
|
-
if (outcome.kind === "rest") return { firstStep: outcome.rest.name };
|
|
25309
|
-
if (outcome.kind === "refused") return { refusal: outcome.message };
|
|
25310
|
-
const why = outcome.kind === "ended" ? "the flow reaches no step" : outcome.message;
|
|
25311
|
-
return { refusal: `workflow "${resolved.name}" cannot start — ${why}` };
|
|
25312
|
-
});
|
|
25313
|
-
/** The review window's diff base at the rest. */
|
|
25314
|
-
const reviewBaseFor = (rest) => rest.step.request.options.base ?? rest.run.diffBase;
|
|
25315
|
-
const renderRest = (rest) => succeed$1({
|
|
25316
|
-
state: rest.state,
|
|
25317
|
-
actor: rest.actor,
|
|
25318
|
-
kind: rest.stepDef.kind,
|
|
25319
|
-
content: rest.stepDef.content,
|
|
25320
|
-
...rest.hints,
|
|
25321
|
-
...rest.memory !== void 0 ? { memory: rest.memory } : {},
|
|
25322
|
-
memoryResumed: rest.memoryResumed
|
|
25083
|
+
...modeCommandSteps(resolved, file)
|
|
25084
|
+
]).required };
|
|
25323
25085
|
});
|
|
25086
|
+
//#endregion
|
|
25087
|
+
//#region src/step/planStep.ts
|
|
25324
25088
|
/**
|
|
25325
|
-
*
|
|
25326
|
-
*
|
|
25089
|
+
* At the human review gate an unconditional `gtd uncheck '<file>'` runs
|
|
25090
|
+
* ahead of the commit, resetting every checkbox before `git add -A` picks it
|
|
25091
|
+
* up — a tick is read-progress, never sign-off.
|
|
25327
25092
|
*/
|
|
25328
|
-
const
|
|
25329
|
-
|
|
25330
|
-
|
|
25331
|
-
|
|
25332
|
-
const
|
|
25333
|
-
|
|
25334
|
-
|
|
25335
|
-
|
|
25336
|
-
|
|
25337
|
-
if (rest.changes.length > 0) return {
|
|
25338
|
-
kind: "refusal",
|
|
25339
|
-
message: `gtd land: approval-gate: ${formatStepId(id)} approves exactly the judged diff — revert your edits and run \`gtd land\` on a clean tree to approve, or run \`gtd abandon\``
|
|
25340
|
-
};
|
|
25341
|
-
if (isNone(approver)) return {
|
|
25342
|
-
kind: "refusal",
|
|
25343
|
-
message: `gtd land: approval-gate: ${formatStepId(id)} records who approved it, but git has no author identity — set user.name and user.email`
|
|
25344
|
-
};
|
|
25345
|
-
if (!isRecordableApproval({
|
|
25346
|
-
step: id,
|
|
25347
|
-
...approver.value
|
|
25348
|
-
})) return {
|
|
25349
|
-
kind: "refusal",
|
|
25350
|
-
message: `gtd land: approval-gate: ${formatStepId(id)} cannot record ${approver.value.name} <${approver.value.email}> as approver — it would not read back unchanged from its Gtd-Approved trailer (a control character, for example)`
|
|
25093
|
+
const uncheckStep = (snapshot) => snapshot.stepDef.actor === "human" && snapshot.stepDef.mode === "review" && snapshot.file !== void 0 ? [{
|
|
25094
|
+
kind: "uncheck",
|
|
25095
|
+
file: snapshot.file
|
|
25096
|
+
}] : [];
|
|
25097
|
+
const gitWriteOf = (landing, message) => {
|
|
25098
|
+
if (landing.only !== void 0) return {
|
|
25099
|
+
kind: "commitPath",
|
|
25100
|
+
path: landing.only,
|
|
25101
|
+
message
|
|
25351
25102
|
};
|
|
25352
|
-
|
|
25353
|
-
|
|
25354
|
-
|
|
25355
|
-
|
|
25356
|
-
const def = rest.stepDef;
|
|
25357
|
-
if (def.kind === "prompt" && def.allowEmpty !== true) return {
|
|
25358
|
-
kind: "attempt",
|
|
25359
|
-
subject: formatSubject(rest.actor, rest.state)
|
|
25103
|
+
if (landing.keepTurn !== void 0) return {
|
|
25104
|
+
kind: "keepTurn",
|
|
25105
|
+
ref: landing.keepTurn,
|
|
25106
|
+
message
|
|
25360
25107
|
};
|
|
25361
|
-
if (
|
|
25362
|
-
kind: "
|
|
25363
|
-
|
|
25108
|
+
if (landing.spec.approved !== void 0) return {
|
|
25109
|
+
kind: "commitClean",
|
|
25110
|
+
message
|
|
25364
25111
|
};
|
|
25365
|
-
};
|
|
25366
|
-
/** At the human review gate the pending tree lands with every tick cleared, as the landing script will. */
|
|
25367
|
-
const reviewGateRewrite = (def) => {
|
|
25368
|
-
const reviewFormat = steeringFormatFor("review");
|
|
25369
|
-
if (!isHumanReviewGate(def) || def.file === void 0 || reviewFormat === void 0) return;
|
|
25370
25112
|
return {
|
|
25371
|
-
|
|
25372
|
-
|
|
25113
|
+
kind: "commitAll",
|
|
25114
|
+
message
|
|
25373
25115
|
};
|
|
25374
25116
|
};
|
|
25375
|
-
const
|
|
25376
|
-
const
|
|
25377
|
-
|
|
25117
|
+
const planStep = (snapshot, opts = {}) => {
|
|
25118
|
+
const landing = snapshot.landing;
|
|
25119
|
+
if (landing.kind === "refusal") return {
|
|
25378
25120
|
kind: "refusal",
|
|
25379
|
-
message:
|
|
25121
|
+
message: landing.message
|
|
25380
25122
|
};
|
|
25381
|
-
|
|
25382
|
-
const landingTarget = (outcome, rest) => {
|
|
25383
|
-
if (outcome.kind === "refused") return succeed$1({
|
|
25384
|
-
kind: "refusal",
|
|
25385
|
-
message: outcome.message
|
|
25386
|
-
});
|
|
25387
|
-
if (outcome.kind === "divergence" || outcome.kind === "failed") return fail$4(new Error(outcome.message));
|
|
25388
|
-
if (outcome.kind !== "rest") return succeed$1(rest.def.initial);
|
|
25389
|
-
const reserved = initialStepRefusal(rest.run.workflow, outcome.rest.name, rest.def.initial);
|
|
25390
|
-
return succeed$1(reserved === void 0 ? outcome.rest.name : reserved);
|
|
25391
|
-
};
|
|
25392
|
-
/**
|
|
25393
|
-
* A step commit entering the default's first step is what ends an episode, so
|
|
25394
|
-
* a named workflow landing there would read back as finished. Its opening
|
|
25395
|
-
* commit may name that step: a bare opening is never read as a boundary.
|
|
25396
|
-
*/
|
|
25397
|
-
const initialStepRefusal = (workflow, step, initial) => workflow !== void 0 && step === initial ? {
|
|
25398
|
-
kind: "refusal",
|
|
25399
|
-
message: `gtd: workflow "${workflow}" reaches step "${step}", the default workflow's first step — that is where a finished process waits, so a started process must never land there; rename the step`
|
|
25400
|
-
} : void 0;
|
|
25401
|
-
const closingTrailers = (branch) => ({
|
|
25402
|
-
version: GTD_VERSION,
|
|
25403
|
-
...isSome(branch) ? { branch: branch.value } : {}
|
|
25404
|
-
});
|
|
25405
|
-
const isSettledNoop = (rest, to) => rest.changes.length === 0 && rest.stepDef.kind === "script" && to === rest.state;
|
|
25406
|
-
const keptTurn = (rest) => rest.stepDef.keep === true && rest.changes.length > 0 ? { keepTurn: keptRef(rest.run.startHash, rest.state) } : {};
|
|
25407
|
-
const approvedTrailer = (rest, approver) => isSome(approver) ? { approved: {
|
|
25408
|
-
step: rest.step.id,
|
|
25409
|
-
...approver.value
|
|
25410
|
-
} } : {};
|
|
25411
|
-
const landingTo = (rest, to, branch, approver, marks) => {
|
|
25412
|
-
if (isSettledNoop(rest, to)) return {
|
|
25123
|
+
if (landing.kind === "noop") return {
|
|
25413
25124
|
kind: "noop",
|
|
25414
|
-
|
|
25125
|
+
state: snapshot.state,
|
|
25126
|
+
settled: landing.settled
|
|
25127
|
+
};
|
|
25128
|
+
const cost = opts.cost === void 0 ? {} : { cost: {
|
|
25129
|
+
cost: opts.cost,
|
|
25130
|
+
...opts.model !== void 0 ? { model: opts.model } : {}
|
|
25131
|
+
} };
|
|
25132
|
+
if (landing.kind === "attempt") {
|
|
25133
|
+
const message = formatCommitMessage({
|
|
25134
|
+
actor: snapshot.actor,
|
|
25135
|
+
to: snapshot.state,
|
|
25136
|
+
...cost
|
|
25137
|
+
});
|
|
25138
|
+
return {
|
|
25139
|
+
kind: "commit",
|
|
25140
|
+
state: snapshot.state,
|
|
25141
|
+
to: snapshot.state,
|
|
25142
|
+
subject: landing.subject,
|
|
25143
|
+
steps: [{
|
|
25144
|
+
kind: "gitWrite",
|
|
25145
|
+
write: {
|
|
25146
|
+
kind: "commitAll",
|
|
25147
|
+
message
|
|
25148
|
+
}
|
|
25149
|
+
}, {
|
|
25150
|
+
kind: "outcome",
|
|
25151
|
+
outcome: {
|
|
25152
|
+
kind: "commit",
|
|
25153
|
+
subject: landing.subject
|
|
25154
|
+
}
|
|
25155
|
+
}]
|
|
25156
|
+
};
|
|
25157
|
+
}
|
|
25158
|
+
const message = formatCommitMessage({
|
|
25159
|
+
...landing.spec,
|
|
25160
|
+
...cost,
|
|
25161
|
+
...opts.judge !== void 0 && opts.judge.length > 0 ? { judge: opts.judge } : {}
|
|
25162
|
+
});
|
|
25163
|
+
const subject = message.split("\n")[0];
|
|
25164
|
+
const outcome = landing.to === snapshot.state ? {
|
|
25165
|
+
kind: "outcome",
|
|
25166
|
+
outcome: {
|
|
25167
|
+
kind: "commit",
|
|
25168
|
+
subject
|
|
25169
|
+
}
|
|
25170
|
+
} : {
|
|
25171
|
+
kind: "outcome",
|
|
25172
|
+
outcome: {
|
|
25173
|
+
kind: "transition",
|
|
25174
|
+
from: snapshot.state,
|
|
25175
|
+
to: landing.to
|
|
25176
|
+
}
|
|
25415
25177
|
};
|
|
25416
|
-
const underway = !noProcessUnderway(rest);
|
|
25417
|
-
const starts = !underway && to !== rest.def.initial;
|
|
25418
|
-
const refused = starts ? multilineRefusal(rest.vars) : void 0;
|
|
25419
|
-
if (refused !== void 0) return refused;
|
|
25420
|
-
const finishes = underway && to === rest.def.initial;
|
|
25421
25178
|
return {
|
|
25422
25179
|
kind: "commit",
|
|
25423
|
-
|
|
25424
|
-
|
|
25425
|
-
|
|
25426
|
-
|
|
25427
|
-
|
|
25428
|
-
|
|
25429
|
-
|
|
25430
|
-
|
|
25431
|
-
|
|
25432
|
-
...
|
|
25433
|
-
|
|
25434
|
-
|
|
25435
|
-
|
|
25180
|
+
state: snapshot.state,
|
|
25181
|
+
to: landing.to,
|
|
25182
|
+
subject,
|
|
25183
|
+
steps: [
|
|
25184
|
+
...uncheckStep(snapshot),
|
|
25185
|
+
{
|
|
25186
|
+
kind: "gitWrite",
|
|
25187
|
+
write: gitWriteOf(landing, message)
|
|
25188
|
+
},
|
|
25189
|
+
...landing.keepHistory !== void 0 ? [{
|
|
25190
|
+
kind: "gitWrite",
|
|
25191
|
+
write: {
|
|
25192
|
+
kind: "updateRef",
|
|
25193
|
+
ref: landing.keepHistory,
|
|
25194
|
+
target: "HEAD"
|
|
25195
|
+
}
|
|
25196
|
+
}] : [],
|
|
25197
|
+
outcome
|
|
25198
|
+
]
|
|
25436
25199
|
};
|
|
25437
25200
|
};
|
|
25438
|
-
|
|
25439
|
-
|
|
25440
|
-
config: rest.config
|
|
25441
|
-
} : {};
|
|
25442
|
-
const outcomeMarks = (outcome) => outcome.kind === "rest" || outcome.kind === "ended" ? outcome.marks : [];
|
|
25201
|
+
//#endregion
|
|
25202
|
+
//#region src/step/accessRefusal.ts
|
|
25443
25203
|
/**
|
|
25444
|
-
*
|
|
25445
|
-
*
|
|
25446
|
-
*
|
|
25204
|
+
* The landing refusal for changes outside `write`, or `undefined` when every
|
|
25205
|
+
* path is allowed. `null` is unrestricted. The message is the fix prompt: gtd
|
|
25206
|
+
* runs no fix turn of its own.
|
|
25447
25207
|
*/
|
|
25448
|
-
const
|
|
25449
|
-
|
|
25450
|
-
|
|
25451
|
-
|
|
25452
|
-
|
|
25453
|
-
|
|
25454
|
-
|
|
25455
|
-
|
|
25456
|
-
|
|
25457
|
-
message
|
|
25458
|
-
};
|
|
25459
|
-
}
|
|
25460
|
-
const outcome = yield* promise(() => replayFor(rest.setup, {
|
|
25461
|
-
tree: pendingTree(rest.setup.workspace, rest.setup.pendingWorktree(), reviewGateRewrite(rest.stepDef)),
|
|
25462
|
-
...verdicts !== void 0 ? { verdicts } : {}
|
|
25463
|
-
}));
|
|
25464
|
-
const to = yield* landingTarget(outcome, rest);
|
|
25465
|
-
if (typeof to !== "string") return to;
|
|
25466
|
-
return landingTo(rest, to, branch, approver, outcomeMarks(outcome));
|
|
25467
|
-
});
|
|
25468
|
-
/** Where landing the pending turn would leave the process, or `undefined` when it would land nothing. */
|
|
25469
|
-
const previewLanding = (rest) => decideLanding(rest, void 0, none(), none()).pipe(map((landing) => landing.kind === "commit" ? landing.to : void 0), catch_(() => succeed$1(void 0)));
|
|
25470
|
-
/** Every fact the pure landing planner needs, read once: the rest, its steering file, and the landing decision. */
|
|
25471
|
-
const snapshotFromRest = (rest, verdicts) => gen(function* () {
|
|
25472
|
-
const git = yield* GitService;
|
|
25473
|
-
const branch = yield* git.currentBranch();
|
|
25474
|
-
const approver = rest.stepDef.approve === true ? yield* git.authorIdent() : none();
|
|
25475
|
-
const landing = yield* decideLanding(rest, verdicts, branch, approver);
|
|
25476
|
-
return {
|
|
25477
|
-
stepDef: rest.stepDef,
|
|
25478
|
-
state: rest.state,
|
|
25479
|
-
actor: rest.actor,
|
|
25480
|
-
changes: rest.changes,
|
|
25481
|
-
file: rest.hints.file,
|
|
25482
|
-
landing
|
|
25483
|
-
};
|
|
25484
|
-
});
|
|
25485
|
-
const humanCommitsOf = (trace, entryCommit) => trace.filter((entry) => entry.actor === "human" && entry.hash !== entryCommit).map((entry) => ({
|
|
25486
|
-
hash: entry.hash,
|
|
25487
|
-
state: entry.state
|
|
25488
|
-
}));
|
|
25489
|
-
const summaryInput = (run, settings) => {
|
|
25490
|
-
const entryCommit = run.trace[0].hash;
|
|
25491
|
-
const { total, byModel } = costTotals(run.costEntries);
|
|
25492
|
-
return {
|
|
25493
|
-
entryCommit,
|
|
25494
|
-
processBase: run.startParentHash,
|
|
25495
|
-
processTip: run.trace[run.trace.length - 1].hash,
|
|
25496
|
-
humanCommits: humanCommitsOf(run.trace, entryCommit),
|
|
25497
|
-
processCost: total,
|
|
25498
|
-
processCostByModel: byModel,
|
|
25499
|
-
marks: run.marks,
|
|
25500
|
-
...settings
|
|
25501
|
-
};
|
|
25208
|
+
const accessRefusal = (changes, write) => {
|
|
25209
|
+
if (write === null) return void 0;
|
|
25210
|
+
const offending = [...new Set(changes.filter((c) => !write.some((glob) => globMatches(c.path, glob))).map((c) => c.path))];
|
|
25211
|
+
if (offending.length === 0) return void 0;
|
|
25212
|
+
return [
|
|
25213
|
+
ACCESS_REFUSAL,
|
|
25214
|
+
...offending.map((path) => ` - ${path}`),
|
|
25215
|
+
`allowed: ${write.length === 0 ? "(nothing)" : write.join(", ")}`
|
|
25216
|
+
].join("\n");
|
|
25502
25217
|
};
|
|
25503
|
-
|
|
25504
|
-
|
|
25505
|
-
|
|
25506
|
-
|
|
25507
|
-
|
|
25508
|
-
if (summary === void 0 || run.trace.length === 0) return { prompt: void 0 };
|
|
25509
|
-
return { prompt: summary(summaryInput(run, settingsFor(resolved, run.pinnedVars, host.env, {}, {}))) };
|
|
25218
|
+
//#endregion
|
|
25219
|
+
//#region src/step/planStart.ts
|
|
25220
|
+
const refusal = (message) => ({
|
|
25221
|
+
kind: "refusal",
|
|
25222
|
+
message
|
|
25510
25223
|
});
|
|
25511
|
-
|
|
25512
|
-
const
|
|
25513
|
-
|
|
25514
|
-
|
|
25515
|
-
return
|
|
25516
|
-
};
|
|
25517
|
-
const configRefusal = (command, tip, paths, reason = "A history ref replays under today's config, so it is unreadable after the config changes") => /* @__PURE__ */ new Error(`${command}: refused — config differs at ${tip.slice(0, 7)}: ${paths.join(", ")}. ${reason}`);
|
|
25518
|
-
const differingConfigPaths = (git, workspace, root, config, run, hash) => gen(function* () {
|
|
25519
|
-
const differing = [];
|
|
25520
|
-
for (const path of inRepoConfigPaths(root, Object.keys(config.fingerprint), run.pinnedConfig?.files ?? {})) if ((yield* orElseSucceed(git.readFileAtRef(hash, path), () => void 0)) !== workspace.readSync(path)) differing.push(path);
|
|
25521
|
-
return differing;
|
|
25224
|
+
const checkOverrides = (commandLabel, varOverrides, declaredNames, envNames) => gen(function* () {
|
|
25225
|
+
const environmentOnly = Object.keys(varOverrides).filter((n) => !declaredNames.includes(n) && envNames.includes(n));
|
|
25226
|
+
if (environmentOnly.length > 0) return yield* fail$4(new GtdUsageError(`${commandLabel}: --var pins process settings only; ${environmentOnly.join(", ")} ${environmentOnly.length === 1 ? "is an environment setting" : "are environment settings"} — set it as a workflow option in gtd.config.ts or with a GTD_<NAME> environment variable`));
|
|
25227
|
+
const undeclared = Object.keys(varOverrides).filter((n) => !declaredNames.includes(n));
|
|
25228
|
+
if (undeclared.length > 0) return yield* fail$4(new GtdUsageError(`${commandLabel}: --var name(s) not declared by this workflow: ${undeclared.join(", ")} — declared: ${declaredNames.length > 0 ? declaredNames.join(", ") : "(none)"}`));
|
|
25522
25229
|
});
|
|
25523
|
-
const notClosedError = (hash, outcome) => outcome.kind === "rest" ? /* @__PURE__ */ new Error(`gtd history: ${hash.slice(0, 7)} does not close a process under today's config`) : replayError(outcome) ?? /* @__PURE__ */ new Error("gtd history: replay failed");
|
|
25524
|
-
const historySteps = (run) => {
|
|
25525
|
-
const messages = new Map(run.episode.commits.map((c) => [c.hash, c.message]));
|
|
25526
|
-
return run.trace.map((entry) => {
|
|
25527
|
-
const message = parseCommitMessage(messages.get(entry.hash) ?? "");
|
|
25528
|
-
return {
|
|
25529
|
-
hash: entry.hash,
|
|
25530
|
-
subject: (messages.get(entry.hash) ?? "").split("\n")[0] ?? "",
|
|
25531
|
-
actor: entry.actor,
|
|
25532
|
-
from: message.parsed?.from,
|
|
25533
|
-
to: entry.state,
|
|
25534
|
-
verdicts: message.judge,
|
|
25535
|
-
...optional("approval", approvalEntryOf(entry.hash, message)[0])
|
|
25536
|
-
};
|
|
25537
|
-
});
|
|
25538
|
-
};
|
|
25539
|
-
const versionWarningFor = (recorded) => recorded === GTD_VERSION ? void 0 : `gtd: warning: this history was recorded by ${recorded === void 0 ? "an unknown gtd version" : `gtd ${recorded}`} and is replayed by gtd ${GTD_VERSION} — the engine the workflow runs on may have changed since`;
|
|
25540
25230
|
/**
|
|
25541
|
-
*
|
|
25542
|
-
*
|
|
25543
|
-
*
|
|
25231
|
+
* `gtd start <name>`: start a brand NEW process on a workflow, writing an
|
|
25232
|
+
* opening commit that names the workflow's first step and carries a
|
|
25233
|
+
* `Gtd-Workflow:` trailer, every process setting as a `Gtd-Var:` trailer, plus
|
|
25234
|
+
* a `Gtd-Review-Base:` trailer when the workflow fixes the process's diff base,
|
|
25235
|
+
* and a `Gtd-Config:` trailer per config file.
|
|
25236
|
+
* All validation is a REFUSAL, not an Effect failure.
|
|
25544
25237
|
*/
|
|
25545
|
-
const
|
|
25546
|
-
const
|
|
25547
|
-
const
|
|
25548
|
-
|
|
25549
|
-
|
|
25550
|
-
const
|
|
25551
|
-
|
|
25552
|
-
|
|
25553
|
-
|
|
25554
|
-
const
|
|
25555
|
-
|
|
25556
|
-
|
|
25238
|
+
const planStart = (current, actor, start) => gen(function* () {
|
|
25239
|
+
const { workflow, first, commandLabel, vars: varOverrides } = start;
|
|
25240
|
+
const name = workflow.name;
|
|
25241
|
+
if (!current.idle) return refusal(`${commandLabel}: a process is already underway (resting at "${current.state}") — finish it, or run \`gtd abandon\`, before starting another`);
|
|
25242
|
+
if ("refusal" in first) return refusal(`${commandLabel}: ${first.refusal}`);
|
|
25243
|
+
const { firstStep } = first;
|
|
25244
|
+
yield* checkOverrides(commandLabel, varOverrides, Object.keys(workflow.vars), Object.keys(workflow.env));
|
|
25245
|
+
const baseOf = workflow.def.base;
|
|
25246
|
+
const flags = yield* StartFlags;
|
|
25247
|
+
const hostEnv = (yield* Host).env;
|
|
25248
|
+
const badFlag = ownFlagRefusal(workflow.vars, flags, hostEnv);
|
|
25249
|
+
if (badFlag !== void 0) return refusal(`${commandLabel}: ${badFlag}`);
|
|
25250
|
+
const vars = resolveStartVars(workflow.vars, flags, varOverrides, hostEnv);
|
|
25251
|
+
const multiline = multilineSetting(vars);
|
|
25252
|
+
if (multiline !== void 0) return refusal(`${commandLabel}: process setting "${multiline}" spans several lines — a process setting is pinned in a commit trailer and must be a single line`);
|
|
25253
|
+
const template = yield* try_({
|
|
25254
|
+
try: () => baseOf?.(name, vars),
|
|
25255
|
+
catch: (e) => /* @__PURE__ */ new Error(`${commandLabel}: ${e instanceof Error ? e.message : String(e)}`)
|
|
25557
25256
|
});
|
|
25257
|
+
const base = template === void 0 ? void 0 : yield* resolveBase(commandLabel, template);
|
|
25258
|
+
if (typeof base === "object") return base;
|
|
25259
|
+
const subject = formatSubject(actor, firstStep);
|
|
25558
25260
|
return {
|
|
25559
|
-
|
|
25560
|
-
|
|
25561
|
-
|
|
25562
|
-
|
|
25563
|
-
|
|
25564
|
-
|
|
25565
|
-
|
|
25566
|
-
|
|
25261
|
+
kind: "start",
|
|
25262
|
+
workflow: name,
|
|
25263
|
+
subject,
|
|
25264
|
+
steps: [{
|
|
25265
|
+
kind: "gitWrite",
|
|
25266
|
+
write: {
|
|
25267
|
+
kind: "commitAll",
|
|
25268
|
+
message: formatCommitMessage({
|
|
25269
|
+
actor,
|
|
25270
|
+
to: firstStep,
|
|
25271
|
+
workflow: name,
|
|
25272
|
+
...base !== void 0 ? { reviewBase: base } : {},
|
|
25273
|
+
vars,
|
|
25274
|
+
config: start.config
|
|
25275
|
+
})
|
|
25276
|
+
}
|
|
25277
|
+
}, {
|
|
25278
|
+
kind: "outcome",
|
|
25279
|
+
outcome: {
|
|
25280
|
+
kind: "commit",
|
|
25281
|
+
subject
|
|
25282
|
+
}
|
|
25283
|
+
}]
|
|
25567
25284
|
};
|
|
25568
25285
|
});
|
|
25569
|
-
/**
|
|
25570
|
-
|
|
25286
|
+
/**
|
|
25287
|
+
* The workflow's diff base: `git merge-base <base> HEAD`, where a blank base
|
|
25288
|
+
* means the default branch. The merge-base is what gets pinned, so a base on
|
|
25289
|
+
* another branch reviews exactly this branch's own commits.
|
|
25290
|
+
*/
|
|
25291
|
+
const resolveBase = (commandLabel, value) => gen(function* () {
|
|
25571
25292
|
const git = yield* GitService;
|
|
25572
|
-
const
|
|
25573
|
-
const
|
|
25574
|
-
const
|
|
25575
|
-
|
|
25576
|
-
const
|
|
25577
|
-
|
|
25578
|
-
if (
|
|
25579
|
-
|
|
25580
|
-
const outcome = yield* promise(() => replayFor(setup));
|
|
25581
|
-
if (outcome.kind !== "ended") return yield* fail$4(withWarning(how.notEnded(outcome)));
|
|
25582
|
-
return {
|
|
25583
|
-
outcome,
|
|
25584
|
-
recorded,
|
|
25585
|
-
...versionWarning !== void 0 ? { versionWarning } : {}
|
|
25586
|
-
};
|
|
25293
|
+
const rendered = value.trim();
|
|
25294
|
+
const target = rendered === "" ? yield* git.defaultBranch() : rendered;
|
|
25295
|
+
const resolvedBase = yield* result(git.resolveRef(target));
|
|
25296
|
+
if (resolvedBase._tag === "Failure") return refusal(`${commandLabel}: "${target}" does not resolve to a commit`);
|
|
25297
|
+
const mergeBase = yield* git.mergeBase(resolvedBase.success, "HEAD");
|
|
25298
|
+
if (isNone(mergeBase)) return refusal(`${commandLabel}: "${target}" shares no common ancestor with HEAD`);
|
|
25299
|
+
if (mergeBase.value === (yield* git.resolveRef("HEAD"))) return refusal(`${commandLabel}: nothing to review: HEAD has no commits beyond ${target}`);
|
|
25300
|
+
return mergeBase.value;
|
|
25587
25301
|
});
|
|
25588
25302
|
//#endregion
|
|
25589
|
-
//#region src/
|
|
25590
|
-
|
|
25303
|
+
//#region src/step/strayPaths.ts
|
|
25304
|
+
/** Globs use the `access.write` dialect (`globMatches`). */
|
|
25305
|
+
const strayPaths = (touched, allow) => touched.filter((path) => !allow.some((glob) => globMatches(path, glob))).sort();
|
|
25306
|
+
//#endregion
|
|
25307
|
+
//#region src/edge/Edge.ts
|
|
25308
|
+
const EMPTY_TREE$1 = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
|
|
25309
|
+
const ACTORS = /* @__PURE__ */ new Set([
|
|
25310
|
+
"agent",
|
|
25311
|
+
"human",
|
|
25312
|
+
"check",
|
|
25313
|
+
"judge"
|
|
25314
|
+
]);
|
|
25315
|
+
const isOpening = ({ parsed, step }) => parsed !== void 0 && step === void 0 && parsed.from === void 0 && parsed.actor === "human";
|
|
25316
|
+
const boundsEpisode = (def, { parsed, pin }) => pin !== "add" && (parsed === void 0 || !ACTORS.has(parsed.actor) || parsed.to === def.initial);
|
|
25591
25317
|
/**
|
|
25592
|
-
*
|
|
25593
|
-
*
|
|
25594
|
-
*
|
|
25595
|
-
*
|
|
25596
|
-
*
|
|
25318
|
+
* Where the episode HEAD belongs to begins, read off subjects alone. Walking
|
|
25319
|
+
* back from HEAD: a bare `gtd(human): <step>` commit with no `Gtd-Step` is what
|
|
25320
|
+
* `gtd start` writes — no landing ever produces one — and opens an
|
|
25321
|
+
* episode as its first commit (its `Gtd-Workflow` trailer names the workflow;
|
|
25322
|
+
* a legacy `--entry` opening has none); a commit that is not a gtd step commit, or one
|
|
25323
|
+
* entering the flow's first step (a finished episode), bounds the episode
|
|
25324
|
+
* from below.
|
|
25597
25325
|
*/
|
|
25598
|
-
const
|
|
25599
|
-
|
|
25600
|
-
|
|
25601
|
-
|
|
25602
|
-
|
|
25603
|
-
|
|
25604
|
-
|
|
25605
|
-
|
|
25606
|
-
|
|
25607
|
-
|
|
25326
|
+
const locateEpisode = (def, history) => {
|
|
25327
|
+
for (let i = history.length - 1; i >= 0; i--) {
|
|
25328
|
+
const message = parseCommitMessage(history[i].message);
|
|
25329
|
+
if (isOpening(message)) return {
|
|
25330
|
+
workflow: message.workflow,
|
|
25331
|
+
legacyOpening: message.workflow === void 0,
|
|
25332
|
+
baseIndex: i,
|
|
25333
|
+
processStart: i
|
|
25334
|
+
};
|
|
25335
|
+
if (boundsEpisode(def, message)) return {
|
|
25336
|
+
workflow: void 0,
|
|
25337
|
+
legacyOpening: false,
|
|
25338
|
+
baseIndex: i,
|
|
25339
|
+
processStart: i + 1
|
|
25340
|
+
};
|
|
25341
|
+
}
|
|
25342
|
+
return {
|
|
25343
|
+
workflow: void 0,
|
|
25344
|
+
legacyOpening: false,
|
|
25345
|
+
baseIndex: -1,
|
|
25346
|
+
processStart: 0
|
|
25347
|
+
};
|
|
25608
25348
|
};
|
|
25609
|
-
const
|
|
25610
|
-
|
|
25611
|
-
const
|
|
25612
|
-
|
|
25613
|
-
|
|
25614
|
-
|
|
25615
|
-
|
|
25616
|
-
|
|
25617
|
-
|
|
25618
|
-
|
|
25619
|
-
|
|
25620
|
-
`if [ -n "$(git status --porcelain --untracked-files=all --ignore-submodules=none)" ]; then`,
|
|
25621
|
-
` printf '%s\\n' ${shellQuote(CHANGED_SINCE_LAND)} >&2`,
|
|
25622
|
-
` exit 1`,
|
|
25623
|
-
`fi`,
|
|
25624
|
-
`gtd_sign_cfg=$(git config --type=bool --default=false commit.gpgSign) || exit 1`,
|
|
25625
|
-
`if [ "$gtd_sign_cfg" = true ]; then gtd_sign=-S; else gtd_sign=--no-gpg-sign; fi`,
|
|
25626
|
-
`gtd_tip=$(git commit-tree "$gtd_sign" "$gtd_head^{tree}" -p "$gtd_head" -m ${shellQuote(message)}) || exit 1`,
|
|
25627
|
-
`git update-ref -m ${shellQuote(`commit: ${message}`)} HEAD "$gtd_tip" "$gtd_head" || exit 1`
|
|
25628
|
-
].join("\n");
|
|
25629
|
-
/** No current caller — see `Git.ts`'s `GitWriterOperations.commitAsIs` for why it's kept anyway. */
|
|
25630
|
-
const commitAsIs = (message) => commitAllowEmpty(message);
|
|
25631
|
-
/** No current caller — see `Git.ts`'s `GitWriterOperations.softResetTo` for why it's kept anyway. */
|
|
25632
|
-
const softResetTo = (ref) => `git reset --soft ${shellQuote(ref)}`;
|
|
25633
|
-
const mixedResetTo = (ref) => `git reset --mixed ${shellQuote(ref)}`;
|
|
25634
|
-
const hardResetTo = (ref) => `git reset --hard ${shellQuote(ref)}`;
|
|
25635
|
-
/**
|
|
25636
|
-
* `git add -A` then `git reset --hard HEAD` — discards every pending change,
|
|
25637
|
-
* tracked or untracked. Joined with `&&`: a failed stage must not reach the
|
|
25638
|
-
* hard reset, or untracked survivors remain — the exact outcome this builder
|
|
25639
|
-
* exists to avoid. No current caller — see `Git.ts`'s
|
|
25640
|
-
* `GitWriterOperations.discardPending` for why it's kept anyway.
|
|
25641
|
-
*/
|
|
25642
|
-
const discardPending = () => `git add -A && git reset --hard HEAD`;
|
|
25643
|
-
/**
|
|
25644
|
-
* POSIX sh (runs under dash). The marker is a `commit-tree` over HEAD's own
|
|
25645
|
-
* tree, so no hook runs on it and its tree is the pre-turn tree by
|
|
25646
|
-
* construction; `commit-tree` ignores `commit.gpgSign`, hence the explicit
|
|
25647
|
-
* `-S`, read (and failing closed) before any object or ref is written; the
|
|
25648
|
-
* index is already staged by then, so an early exit leaves the turn staged.
|
|
25649
|
-
* The kept ref is deleted only after `reset -q` succeeds: it is then the
|
|
25650
|
-
* turn's only handle.
|
|
25651
|
-
* The branch moves before the final hard reset, so if that reset fails the
|
|
25652
|
-
* marker has landed and the turn is still in the working tree (kept ref
|
|
25653
|
-
* present); every other failure leaves the branch untouched.
|
|
25654
|
-
*/
|
|
25655
|
-
const keepTurn = (refPrefix, message) => {
|
|
25656
|
-
const keptRef = `${shellQuote(`${refPrefix}/`)}"$gtd_kept"`;
|
|
25657
|
-
return [
|
|
25658
|
-
`git add -A || exit 1`,
|
|
25659
|
-
`gtd_sign_cfg=$(git config --type=bool --default=false commit.gpgSign) || exit 1`,
|
|
25660
|
-
`if [ "$gtd_sign_cfg" = true ]; then gtd_sign=-S; else gtd_sign=--no-gpg-sign; fi`,
|
|
25661
|
-
`gtd_head=$(git rev-parse --verify HEAD) || exit 1`,
|
|
25662
|
-
`gtd_tree=$(git write-tree) || exit 1`,
|
|
25663
|
-
`gtd_kept=$(git commit-tree "$gtd_tree" -p "$gtd_head" -m ${shellQuote(message)}) || exit 1`,
|
|
25664
|
-
`git update-ref ${keptRef} "$gtd_kept" || exit 1`,
|
|
25665
|
-
`gtd_marker=$(printf '%s\\nGtd-Kept: %s' ${shellQuote(message)} "$gtd_kept")`,
|
|
25666
|
-
`if ! gtd_tip=$(git commit-tree "$gtd_sign" "$gtd_head^{tree}" -p "$gtd_head" -m "$gtd_marker") || ! git update-ref -m ${shellQuote(`commit: ${message}`)} HEAD "$gtd_tip" "$gtd_head"; then`,
|
|
25667
|
-
` git reset -q && git update-ref -d ${keptRef}`,
|
|
25668
|
-
` exit 1`,
|
|
25669
|
-
`fi`,
|
|
25670
|
-
`git reset --hard HEAD`
|
|
25671
|
-
].join("\n");
|
|
25349
|
+
const headTurnOf = (head) => {
|
|
25350
|
+
if (head === void 0) return void 0;
|
|
25351
|
+
const message = parseCommitMessage(head.message);
|
|
25352
|
+
const subject = message.parsed;
|
|
25353
|
+
if (subject === void 0 || !ACTORS.has(subject.actor) || message.pin !== void 0) return void 0;
|
|
25354
|
+
return {
|
|
25355
|
+
state: subject.to,
|
|
25356
|
+
actor: subject.actor,
|
|
25357
|
+
empty: head.touched.length === 0,
|
|
25358
|
+
step: message.step !== void 0
|
|
25359
|
+
};
|
|
25672
25360
|
};
|
|
25673
|
-
const
|
|
25674
|
-
|
|
25675
|
-
|
|
25676
|
-
|
|
25677
|
-
|
|
25678
|
-
|
|
25679
|
-
|
|
25680
|
-
|
|
25681
|
-
|
|
25682
|
-
|
|
25683
|
-
|
|
25684
|
-
|
|
25685
|
-
|
|
25686
|
-
|
|
25687
|
-
|
|
25688
|
-
|
|
25689
|
-
const
|
|
25690
|
-
const
|
|
25691
|
-
|
|
25692
|
-
case "transition": return printfLine$1("%s %s → %s\n", [
|
|
25693
|
-
"'->'",
|
|
25694
|
-
shellQuote(outcome.from),
|
|
25695
|
-
shellQuote(outcome.to)
|
|
25696
|
-
]);
|
|
25697
|
-
case "commit": return printfLine$1("%s %s\n", ["'[commit]'", shellQuote(outcome.subject)]);
|
|
25698
|
-
case "note": return printfLine$1("%s\n", [shellQuote(outcome.text)]);
|
|
25699
|
-
}
|
|
25361
|
+
const traceEntryOf = (hash, message) => ({
|
|
25362
|
+
state: message.parsed?.to ?? "",
|
|
25363
|
+
hash,
|
|
25364
|
+
actor: message.parsed?.actor ?? ""
|
|
25365
|
+
});
|
|
25366
|
+
const costEntriesOf = (hash, message) => message.cost.map((c) => ({
|
|
25367
|
+
cost: c.cost,
|
|
25368
|
+
model: c.model ?? "unspecified",
|
|
25369
|
+
hash
|
|
25370
|
+
}));
|
|
25371
|
+
const hashAt = (history, index) => history[index]?.hash;
|
|
25372
|
+
const historyFormatFault = (history, location) => formatFault(history.slice(location.baseIndex + 1).concat(history[location.baseIndex]?.message.startsWith("gtd") ? [history[location.baseIndex]] : []).map((c) => ({
|
|
25373
|
+
hash: c.hash,
|
|
25374
|
+
format: parseCommitMessage(c.message).format
|
|
25375
|
+
})));
|
|
25376
|
+
/** Fails on a history in a format this gtd does not read, before anything acts on it. */
|
|
25377
|
+
const runOf = (history, location, closingHash = void 0) => {
|
|
25378
|
+
const fault = historyFormatFault(history, location);
|
|
25379
|
+
return fault === void 0 ? succeed$1(buildRun(history, location, closingHash)) : fail$4(new Error(fault));
|
|
25700
25380
|
};
|
|
25701
|
-
const
|
|
25702
|
-
|
|
25703
|
-
|
|
25704
|
-
|
|
25705
|
-
|
|
25706
|
-
|
|
25381
|
+
const pinnedConfigOf = (history) => {
|
|
25382
|
+
for (let i = history.length - 1; i >= 0; i--) {
|
|
25383
|
+
const files = parseCommitMessage(history[i].message).config;
|
|
25384
|
+
if (Object.keys(files).length > 0) return {
|
|
25385
|
+
commit: history[i].hash,
|
|
25386
|
+
files
|
|
25387
|
+
};
|
|
25707
25388
|
}
|
|
25708
25389
|
};
|
|
25709
|
-
|
|
25710
|
-
|
|
25711
|
-
|
|
25712
|
-
|
|
25713
|
-
|
|
25714
|
-
|
|
25715
|
-
const
|
|
25716
|
-
|
|
25717
|
-
|
|
25718
|
-
|
|
25719
|
-
|
|
25720
|
-
|
|
25721
|
-
|
|
25722
|
-
|
|
25723
|
-
|
|
25724
|
-
|
|
25725
|
-
|
|
25726
|
-
|
|
25727
|
-
|
|
25728
|
-
|
|
25729
|
-
|
|
25730
|
-
|
|
25731
|
-
|
|
25390
|
+
const approvalEntryOf = (hash, message) => message.approved === void 0 ? [] : [{
|
|
25391
|
+
step: formatStepId(message.approved.step),
|
|
25392
|
+
name: message.approved.name,
|
|
25393
|
+
email: message.approved.email,
|
|
25394
|
+
commit: hash
|
|
25395
|
+
}];
|
|
25396
|
+
const buildRun = (history, location, closingHash) => {
|
|
25397
|
+
const allProcessCommits = history.slice(location.processStart);
|
|
25398
|
+
const allParsed = allProcessCommits.map((c) => parseCommitMessage(c.message));
|
|
25399
|
+
const processCommits = allProcessCommits.filter((_, i) => allParsed[i].pin !== "add");
|
|
25400
|
+
const parsed = allParsed.filter((m) => m.pin !== "add");
|
|
25401
|
+
const first = parsed[0];
|
|
25402
|
+
const startParentHash = hashAt(history, location.processStart - 1) ?? EMPTY_TREE$1;
|
|
25403
|
+
const head = history[history.length - 1];
|
|
25404
|
+
return {
|
|
25405
|
+
workflow: location.workflow,
|
|
25406
|
+
legacyOpening: location.legacyOpening,
|
|
25407
|
+
startHash: hashAt(history, location.processStart) ?? head?.hash ?? EMPTY_TREE$1,
|
|
25408
|
+
startParentHash,
|
|
25409
|
+
diffBase: first?.reviewBase ?? startParentHash,
|
|
25410
|
+
trace: processCommits.map((c, i) => traceEntryOf(c.hash, parsed[i])),
|
|
25411
|
+
costEntries: parsed.flatMap((m, i) => costEntriesOf(processCommits[i].hash, m)),
|
|
25412
|
+
judgeVerdicts: parsed.flatMap((m) => m.judge),
|
|
25413
|
+
pinCommitted: allParsed.some((m) => m.pin === "add"),
|
|
25414
|
+
marks: parsed.flatMap((m) => m.marks),
|
|
25415
|
+
approvals: processCommits.flatMap((c, i) => approvalEntryOf(c.hash, parsed[i])),
|
|
25416
|
+
pinnedVars: { ...first?.vars },
|
|
25417
|
+
pinnedConfig: pinnedConfigOf(history.slice(Math.max(location.baseIndex, 0))),
|
|
25418
|
+
headTurn: headTurnOf(head),
|
|
25419
|
+
closingHash,
|
|
25420
|
+
episode: {
|
|
25421
|
+
base: hashAt(history, location.baseIndex),
|
|
25422
|
+
commits: history.slice(location.baseIndex + 1)
|
|
25423
|
+
}
|
|
25424
|
+
};
|
|
25732
25425
|
};
|
|
25733
|
-
const
|
|
25734
|
-
|
|
25735
|
-
|
|
25736
|
-
|
|
25737
|
-
|
|
25738
|
-
|
|
25739
|
-
/** Exits 0 when the declared steering file is absent — nothing to format or validate yet. An OR list, not `if`: its left side failing never trips `set -e`. */
|
|
25740
|
-
const fileExistsGuard = (file) => `[ -f ${shellQuote(file)} ] || exit 0`;
|
|
25741
|
-
/** A mode or `ui.format` command finds the file it works on in `$GTD_FILE`. */
|
|
25742
|
-
const withFileVar = (command, file) => `export GTD_FILE=${shellQuote(file)}\n${command}`;
|
|
25743
|
-
const SIMPLE_LEADING_WORD_RE = /^[A-Za-z0-9_./-]+(?=[ \t]|$)/;
|
|
25744
|
-
const SHELL_METACHARACTER_RE = /[|&;$<>(){}`]/;
|
|
25745
|
-
const FILE_VAR_RE = /"\$GTD_FILE"|"\$\{GTD_FILE\}"|\$GTD_FILE\b|\$\{GTD_FILE\}/g;
|
|
25746
|
-
/** `command` with `file` written in for `$GTD_FILE` — one line an agent can copy, equivalent to running it under `withFileVar`. */
|
|
25747
|
-
const commandForFile = (command, file) => command.replace(FILE_VAR_RE, shellQuote(file));
|
|
25426
|
+
const PAGE_TIERS = [
|
|
25427
|
+
32,
|
|
25428
|
+
128,
|
|
25429
|
+
512,
|
|
25430
|
+
Infinity
|
|
25431
|
+
];
|
|
25748
25432
|
/**
|
|
25749
|
-
*
|
|
25750
|
-
*
|
|
25751
|
-
*
|
|
25752
|
-
*
|
|
25433
|
+
* `locateEpisode`'s own boundary test, subject-only: a commit that isn't a
|
|
25434
|
+
* gtd step commit, or one landing the flow's initial state, bounds an
|
|
25435
|
+
* episode from below — EXCEPT a bare `gtd(human): <x>` subject (no `→`),
|
|
25436
|
+
* which is NEVER a boundary here, whatever `<x>` is. That bare shape is
|
|
25437
|
+
* `locateEpisode`'s own `opening` branch (a started episode's first commit),
|
|
25438
|
+
* which takes priority there over the initial-state check no matter what the
|
|
25439
|
+
* workflow's first step is named — including one named after `def.initial`
|
|
25440
|
+
* itself, where the two branches would otherwise disagree. A bare human commit can
|
|
25441
|
+
* also be a self-loop step landing rather than a real opening (this
|
|
25442
|
+
* subject-only pass can't read the `Gtd-Step` trailer that would tell them
|
|
25443
|
+
* apart), so ruling it out here can only make `findBoundaryBase` walk a FEW
|
|
25444
|
+
* commits further back than strictly needed — never stop short of the
|
|
25445
|
+
* boundary `locateEpisode` would find once it has full commit bodies in
|
|
25446
|
+
* hand. That asymmetry is the only direction this approximation may err in;
|
|
25447
|
+
* it must never call a real boundary "not yet found".
|
|
25753
25448
|
*/
|
|
25754
|
-
const
|
|
25755
|
-
|
|
25756
|
-
if (
|
|
25757
|
-
|
|
25758
|
-
|
|
25449
|
+
const subjectIsBoundary = (def, subject) => {
|
|
25450
|
+
const parsed = parseCommitMessage(subject).parsed;
|
|
25451
|
+
if (parsed === void 0 || !ACTORS.has(parsed.actor)) return true;
|
|
25452
|
+
if (parsed.actor === "human" && parsed.from === void 0) return false;
|
|
25453
|
+
if (parsed.actor === "check" && parsed.from === PIN_STATES.add) return false;
|
|
25454
|
+
return parsed.to === def.initial;
|
|
25759
25455
|
};
|
|
25760
|
-
/**
|
|
25761
|
-
|
|
25762
|
-
|
|
25763
|
-
* resolves it in-process. Exits 127, matching bash's "command not found".
|
|
25764
|
-
*/
|
|
25765
|
-
const binaryGuard = (binary, mode, key) => {
|
|
25766
|
-
const messageQ = shellQuote(`gtd: mode "${mode}": "${key}" command not found: ${binary}`);
|
|
25767
|
-
return [
|
|
25768
|
-
`command -v ${shellQuote(binary)} >/dev/null 2>&1 || {`,
|
|
25769
|
-
` printf '%s\\n%s\\n' ${messageQ} '$PATH: '"$PATH"`,
|
|
25770
|
-
` exit 127`,
|
|
25771
|
-
`}`
|
|
25772
|
-
].join("\n");
|
|
25456
|
+
/** The last page index (walking newest→oldest, same direction as `locateEpisode`) whose subject is a boundary, or `undefined` if none in `page`. */
|
|
25457
|
+
const boundaryIndexIn = (def, page) => {
|
|
25458
|
+
for (let i = page.length - 1; i >= 0; i--) if (subjectIsBoundary(def, page[i].subject)) return i;
|
|
25773
25459
|
};
|
|
25774
25460
|
/**
|
|
25775
|
-
*
|
|
25776
|
-
*
|
|
25777
|
-
*
|
|
25461
|
+
* `findBoundaryBase`'s result: `base` is the hash to pass `commitHistory` so
|
|
25462
|
+
* a `git.commitHistory(base, head)` call reads exactly the episode ending at
|
|
25463
|
+
* `head`, plus whatever a subject-only read couldn't rule out — never the
|
|
25464
|
+
* whole repository. `base` is deliberately the boundary commit's PARENT, not
|
|
25465
|
+
* the boundary commit itself: `commitHistory`'s range excludes `base`, and
|
|
25466
|
+
* `runOf` needs the boundary commit present in the read history
|
|
25467
|
+
* (`episode.base`, `startParentHash`). `base: undefined` means "read from the
|
|
25468
|
+
* repository root" — an empty repository, an unresolvable `head`, or an
|
|
25469
|
+
* episode that reaches the root commit, none of which cost anything extra
|
|
25470
|
+
* since that IS the whole history in those cases.
|
|
25471
|
+
*
|
|
25472
|
+
* `head` is the CONCRETE hash the literal `head` argument resolved to on this
|
|
25473
|
+
* call's first read — `undefined` only when it never resolved (an empty
|
|
25474
|
+
* repository, or an unresolvable ref). The caller must hand this hash, not
|
|
25475
|
+
* the original `head` argument, to its own `commitHistory` call: resolving a
|
|
25476
|
+
* symbolic ref (literal `HEAD`, most callers' default) a second time risks
|
|
25477
|
+
* reading a DIFFERENT commit than this function paged through, if something
|
|
25478
|
+
* moves HEAD in between — pinning both reads to one resolved hash is what
|
|
25479
|
+
* keeps the two-read split from disagreeing with itself.
|
|
25778
25480
|
*/
|
|
25779
|
-
const
|
|
25780
|
-
|
|
25781
|
-
|
|
25782
|
-
|
|
25783
|
-
|
|
25784
|
-
|
|
25785
|
-
|
|
25786
|
-
|
|
25787
|
-
|
|
25788
|
-
|
|
25789
|
-
|
|
25790
|
-
};
|
|
25791
|
-
|
|
25792
|
-
|
|
25793
|
-
|
|
25481
|
+
const findBoundaryBase = (git, def, head) => gen(function* () {
|
|
25482
|
+
let pinnedHead = head;
|
|
25483
|
+
for (const pageSize of PAGE_TIERS) {
|
|
25484
|
+
const page = yield* git.subjectHistory(pageSize, 0, pinnedHead);
|
|
25485
|
+
if (pinnedHead === void 0) pinnedHead = page[page.length - 1]?.hash;
|
|
25486
|
+
const wholeHistory = !Number.isFinite(pageSize) || page.length < pageSize;
|
|
25487
|
+
const boundaryIdx = boundaryIndexIn(def, page);
|
|
25488
|
+
if (boundaryIdx === void 0 || boundaryIdx === 0) {
|
|
25489
|
+
if (wholeHistory) return {
|
|
25490
|
+
base: void 0,
|
|
25491
|
+
head: pinnedHead
|
|
25492
|
+
};
|
|
25493
|
+
continue;
|
|
25494
|
+
}
|
|
25495
|
+
return {
|
|
25496
|
+
base: page[boundaryIdx - 1].hash,
|
|
25497
|
+
head: pinnedHead
|
|
25498
|
+
};
|
|
25499
|
+
}
|
|
25500
|
+
return {
|
|
25501
|
+
base: void 0,
|
|
25502
|
+
head: pinnedHead
|
|
25503
|
+
};
|
|
25504
|
+
});
|
|
25505
|
+
const historyUpTo = (git, def, head) => flatMap(findBoundaryBase(git, def, head), ({ base, head: resolvedHead }) => git.commitHistory(base, resolvedHead));
|
|
25506
|
+
const computeProcessRun = (git, def, head) => flatMap(historyUpTo(git, def, head), (history) => runOf(history, locateEpisode(def, history)));
|
|
25507
|
+
const changedSinceStart = (pinned, current, workspace) => {
|
|
25508
|
+
if (pinned === void 0) return [];
|
|
25509
|
+
return changedConfigFiles(pinned.files, current).filter((path) => {
|
|
25510
|
+
const committed = workspace.readCommittedSync(path, pinned.commit);
|
|
25511
|
+
return current[path] === void 0 || committed === void 0 || contentHash(committed) !== current[path];
|
|
25512
|
+
});
|
|
25794
25513
|
};
|
|
25795
|
-
const
|
|
25796
|
-
|
|
25797
|
-
|
|
25798
|
-
|
|
25799
|
-
optional: assembleScript(optional)
|
|
25514
|
+
const warnConfigChanges = (run, current, workspace) => gen(function* () {
|
|
25515
|
+
const changed = changedSinceStart(run.pinnedConfig, current, workspace);
|
|
25516
|
+
if (changed.length === 0) return;
|
|
25517
|
+
yield* (yield* Narrator).warn(`gtd: warning: the config changed since this process started (${changed.join(", ")}) — the rest of the process replays under the changed config`);
|
|
25800
25518
|
});
|
|
25801
|
-
/**
|
|
25802
|
-
const
|
|
25803
|
-
const
|
|
25804
|
-
const
|
|
25805
|
-
|
|
25806
|
-
|
|
25807
|
-
|
|
25808
|
-
|
|
25809
|
-
|
|
25810
|
-
|
|
25811
|
-
|
|
25812
|
-
if (
|
|
25813
|
-
|
|
25814
|
-
|
|
25815
|
-
|
|
25816
|
-
|
|
25817
|
-
|
|
25818
|
-
|
|
25819
|
-
|
|
25820
|
-
|
|
25821
|
-
].
|
|
25519
|
+
/** The run alone, never replaying — `gtd abandon` must work even when replay would refuse, short of a history it cannot read. */
|
|
25520
|
+
const currentRun = gen(function* () {
|
|
25521
|
+
const git = yield* GitService;
|
|
25522
|
+
const config = yield* (yield* ConfigService).load;
|
|
25523
|
+
return yield* computeProcessRun(git, config.workflow);
|
|
25524
|
+
});
|
|
25525
|
+
const runAtTip = (git, def, tip) => gen(function* () {
|
|
25526
|
+
if (tip === void 0) {
|
|
25527
|
+
const history = yield* historyUpTo(git, def, void 0);
|
|
25528
|
+
return yield* runOf(history, locateEpisode(def, history));
|
|
25529
|
+
}
|
|
25530
|
+
if (!(parseCommitMessage(tip.subject).parsed?.to === def.initial)) {
|
|
25531
|
+
const history = yield* historyUpTo(git, def, tip.hash);
|
|
25532
|
+
return yield* runOf(history, locateEpisode(def, history));
|
|
25533
|
+
}
|
|
25534
|
+
const parentRef = `${tip.hash}~1`;
|
|
25535
|
+
const before = yield* historyUpTo(git, def, parentRef);
|
|
25536
|
+
const location = locateEpisode(def, before);
|
|
25537
|
+
let closing = yield* git.commitHistory(parentRef, tip.hash);
|
|
25538
|
+
if (closing.length === 0) closing = yield* git.commitHistory(void 0, tip.hash);
|
|
25539
|
+
return yield* runOf([...before, ...closing], { ...location }, tip.hash);
|
|
25540
|
+
});
|
|
25541
|
+
const runAtHead = (git, def) => gen(function* () {
|
|
25542
|
+
return yield* runAtTip(git, def, (yield* git.subjectHistory(1, 0))[0]);
|
|
25543
|
+
});
|
|
25544
|
+
/** The process HEAD closes or sits inside — `gtd summary`'s run. */
|
|
25545
|
+
const summaryRun = gen(function* () {
|
|
25546
|
+
const git = yield* GitService;
|
|
25547
|
+
const def = (yield* (yield* ConfigService).load).workflow;
|
|
25548
|
+
return yield* runAtHead(git, def);
|
|
25549
|
+
});
|
|
25550
|
+
const commitTree = (workspace, hash) => {
|
|
25551
|
+
let entries;
|
|
25552
|
+
const list = () => entries ??= workspace.treeSync(hash);
|
|
25553
|
+
let sortedPaths;
|
|
25554
|
+
const contents = /* @__PURE__ */ new Map();
|
|
25555
|
+
return {
|
|
25556
|
+
paths: () => sortedPaths ??= [...list().keys()].sort(),
|
|
25557
|
+
read: (path) => {
|
|
25558
|
+
if (!list().has(path)) return void 0;
|
|
25559
|
+
if (!contents.has(path)) contents.set(path, workspace.readCommittedSync(path, hash));
|
|
25560
|
+
return contents.get(path);
|
|
25561
|
+
},
|
|
25562
|
+
id: (path) => list().get(path)
|
|
25563
|
+
};
|
|
25822
25564
|
};
|
|
25823
|
-
//#endregion
|
|
25824
|
-
//#region src/emit/OutcomeScript.ts
|
|
25825
25565
|
/**
|
|
25826
|
-
*
|
|
25827
|
-
*
|
|
25828
|
-
*
|
|
25829
|
-
*
|
|
25830
|
-
* `%` can never be read as a conversion spec. Markers like `->` are arguments
|
|
25831
|
-
* too, keeping every format string free of a leading `-` that a shell's
|
|
25832
|
-
* `printf` could mistake for an option. A real newline in `fmt` is emitted as
|
|
25833
|
-
* the two-character `\n` escape `printf` itself expands, keeping every emitted
|
|
25834
|
-
* statement on one line.
|
|
25566
|
+
* The tree a landing would commit: the working tree, with any rewrite the
|
|
25567
|
+
* landing script applies first. `entries` is `setup.pendingWorktree()` —
|
|
25568
|
+
* already memoized — never a fresh `workspace.worktreeSync()` call; see that
|
|
25569
|
+
* field's own comment for why the memo lives there and not on `Workspace`.
|
|
25835
25570
|
*/
|
|
25836
|
-
const
|
|
25571
|
+
const pendingTree = (workspace, entries, rewrite) => {
|
|
25572
|
+
const paths = [...entries.keys()];
|
|
25573
|
+
const readWorktree = (path) => {
|
|
25574
|
+
try {
|
|
25575
|
+
return workspace.readSync(path);
|
|
25576
|
+
} catch {
|
|
25577
|
+
return;
|
|
25578
|
+
}
|
|
25579
|
+
};
|
|
25580
|
+
return {
|
|
25581
|
+
paths: () => paths,
|
|
25582
|
+
read: (path) => {
|
|
25583
|
+
if (!entries.has(path)) return void 0;
|
|
25584
|
+
const content = readWorktree(path);
|
|
25585
|
+
return content === void 0 || rewrite?.path !== path ? content : rewrite.apply(content);
|
|
25586
|
+
},
|
|
25587
|
+
id: (path) => rewrite?.path === path ? void 0 : entries.get(path)
|
|
25588
|
+
};
|
|
25589
|
+
};
|
|
25837
25590
|
/**
|
|
25838
|
-
*
|
|
25839
|
-
*
|
|
25840
|
-
*
|
|
25841
|
-
* `
|
|
25591
|
+
* The one resolver of both setting kinds. Process settings: the recorded
|
|
25592
|
+
* (pinned) value wins per name, anything unrecorded resolves live — the
|
|
25593
|
+
* fallback for a process started before pinning existed. Environment settings
|
|
25594
|
+
* are always live. `startOverrides` are `--var` values, passed only while a
|
|
25595
|
+
* process is being started.
|
|
25842
25596
|
*/
|
|
25843
|
-
const
|
|
25844
|
-
|
|
25845
|
-
|
|
25846
|
-
|
|
25847
|
-
|
|
25848
|
-
|
|
25849
|
-
|
|
25850
|
-
|
|
25597
|
+
const settingsFor = (workflow, pinnedVars, hostEnv, startOverrides = {}, startFlags) => ({
|
|
25598
|
+
vars: {
|
|
25599
|
+
...resolveStartVars(workflow.vars, startFlags, startOverrides, hostEnv),
|
|
25600
|
+
...pinnedVars
|
|
25601
|
+
},
|
|
25602
|
+
env: resolveVars(workflow.env, {}, hostEnv)
|
|
25603
|
+
});
|
|
25604
|
+
/** The workflow a run replays on: its pinned one, else the default. */
|
|
25605
|
+
const workflowOf = (config, run) => {
|
|
25606
|
+
if (run.legacyOpening) return fail$4(/* @__PURE__ */ new Error("gtd: this process was opened by `gtd --entry`, which no longer exists — run `gtd abandon` to start over"));
|
|
25607
|
+
if (run.workflow === void 0) return succeed$1(config.defaultWorkflow);
|
|
25608
|
+
const named = config.workflowNamed(run.workflow);
|
|
25609
|
+
if (named !== void 0) return succeed$1(named);
|
|
25610
|
+
return fail$4(/* @__PURE__ */ new Error(`gtd: this process runs workflow "${run.workflow}", which gtd.config.ts no longer defines — run \`gtd abandon\` to start over`));
|
|
25611
|
+
};
|
|
25612
|
+
/**
|
|
25613
|
+
* `judgeBudgetBytes` is the one var that refuses rather than disabling its
|
|
25614
|
+
* mechanism when blanked: without a bound the judge payload is rejected
|
|
25615
|
+
* anyway. Absent falls back to a conservative default.
|
|
25616
|
+
*/
|
|
25617
|
+
const DEFAULT_JUDGE_BUDGET_BYTES = 32768;
|
|
25618
|
+
const judgeBudgetBytes = (vars) => {
|
|
25619
|
+
const raw = vars.judgeBudgetBytes;
|
|
25620
|
+
if (raw === void 0) return DEFAULT_JUDGE_BUDGET_BYTES;
|
|
25621
|
+
const n = Number(raw);
|
|
25622
|
+
if (raw.trim() === "" || !Number.isInteger(n) || n <= 0) throw new Error(`"judgeBudgetBytes" must be a positive integer — got ${JSON.stringify(raw)} (blanking this var disables the payload bound rather than the mechanism it guards, so it is refused rather than defaulted)`);
|
|
25623
|
+
return n;
|
|
25624
|
+
};
|
|
25625
|
+
const replayFor = (setup, pending) => replay({
|
|
25626
|
+
flow: setup.def.flow,
|
|
25627
|
+
episode: {
|
|
25628
|
+
base: setup.base,
|
|
25629
|
+
commits: setup.episode
|
|
25630
|
+
},
|
|
25631
|
+
vars: setup.vars,
|
|
25632
|
+
env: setup.env,
|
|
25633
|
+
start: setup.run.diffBase,
|
|
25634
|
+
startTree: setup.run.diffBase === EMPTY_TREE$1 ? treeFromRecord({}) : commitTree(setup.workspace, setup.run.diffBase),
|
|
25635
|
+
budgetBytes: setup.budget,
|
|
25636
|
+
skills: setup.skills,
|
|
25637
|
+
configuredSkills: setup.def.configuredSkills,
|
|
25638
|
+
access: setup.access,
|
|
25639
|
+
configuredAccess: setup.def.configuredAccess,
|
|
25640
|
+
...pending !== void 0 ? { pending } : {}
|
|
25641
|
+
});
|
|
25642
|
+
const replayError = (outcome) => {
|
|
25643
|
+
if (outcome.kind === "divergence" || outcome.kind === "failed") return new Error(outcome.message);
|
|
25644
|
+
if (outcome.kind === "refused") return /* @__PURE__ */ new Error(`gtd: ${outcome.message}`);
|
|
25645
|
+
if (outcome.kind === "ended") return /* @__PURE__ */ new Error("gtd: history ends the episode, but HEAD does not enter the default workflow's first step — the workflow changed under this process; run `gtd abandon` to start over");
|
|
25646
|
+
};
|
|
25647
|
+
const judgeDocument = (step) => step.request.kind === "judge" ? judgeJson(step.request.evidence, step.request.questions) : void 0;
|
|
25648
|
+
/**
|
|
25649
|
+
* The fixed sentence appended to a judge gate's message when a bounded read
|
|
25650
|
+
* dropped bytes to fit `judgeBudgetBytes` — a constant, so no workflow can
|
|
25651
|
+
* reword or drop it.
|
|
25652
|
+
*/
|
|
25653
|
+
const TRUNCATION_NOTICE = "Note: some evidence above was truncated to fit the judge's payload budget.";
|
|
25654
|
+
const DEFAULT_JUDGE_MESSAGE = "A judgment is pending. Run `gtd judge answer` and pipe a verdict, or land to take the conservative default with no verdict recorded.";
|
|
25655
|
+
const optional = (key, value) => value === void 0 ? {} : { [key]: value };
|
|
25656
|
+
const commonOf = (step) => {
|
|
25657
|
+
const options = step.request.options;
|
|
25658
|
+
return {
|
|
25659
|
+
actor: step.actor,
|
|
25660
|
+
...optional("label", options.label),
|
|
25661
|
+
...optional("file", options.file),
|
|
25662
|
+
...optional("mode", options.mode)
|
|
25663
|
+
};
|
|
25664
|
+
};
|
|
25665
|
+
const promptDef = (common, request, skills, access) => ({
|
|
25666
|
+
...common,
|
|
25667
|
+
kind: "prompt",
|
|
25668
|
+
content: request.prompt,
|
|
25669
|
+
...optional("model", request.options.model),
|
|
25670
|
+
...optional("system", request.options.system),
|
|
25671
|
+
...optional("allowEmpty", request.options.allowEmpty),
|
|
25672
|
+
...optional("keep", request.options.keep),
|
|
25673
|
+
...optional("skills", skills),
|
|
25674
|
+
...optional("access", access)
|
|
25675
|
+
});
|
|
25676
|
+
const scriptDef = (common, request) => ({
|
|
25677
|
+
...common,
|
|
25678
|
+
kind: "script",
|
|
25679
|
+
content: request.body
|
|
25680
|
+
});
|
|
25681
|
+
const judgeDef = (common, request, step) => {
|
|
25682
|
+
const message = request.options.message ?? DEFAULT_JUDGE_MESSAGE;
|
|
25683
|
+
return {
|
|
25684
|
+
...common,
|
|
25685
|
+
kind: "message",
|
|
25686
|
+
content: step.truncated ? `${message}\n\n${TRUNCATION_NOTICE}` : message,
|
|
25687
|
+
...optional("judge", judgeDocument(step))
|
|
25688
|
+
};
|
|
25689
|
+
};
|
|
25690
|
+
const messageDef = (common, request, step) => ({
|
|
25691
|
+
...common,
|
|
25692
|
+
kind: "message",
|
|
25693
|
+
content: request.options.message ?? request.options.label ?? step.name,
|
|
25694
|
+
...optional("acceptClean", request.options.approve === true || request.options.acceptClean),
|
|
25695
|
+
...optional("approve", request.options.approve)
|
|
25696
|
+
});
|
|
25697
|
+
const stepDefOf = (step) => {
|
|
25698
|
+
const request = step.request;
|
|
25699
|
+
const common = commonOf(step);
|
|
25700
|
+
switch (request.kind) {
|
|
25701
|
+
case "agent": return promptDef(common, request, step.skills, step.access);
|
|
25702
|
+
case "run": return scriptDef(common, request);
|
|
25703
|
+
case "judge": return judgeDef(common, request, step);
|
|
25704
|
+
case "human": return messageDef(common, request, step);
|
|
25705
|
+
}
|
|
25706
|
+
};
|
|
25707
|
+
const ROOT_MEMORY_SCOPE_NAME = "root";
|
|
25708
|
+
/**
|
|
25709
|
+
* An agent rest's memory key, `<scope>#<hash7>`: the hash is the commit the
|
|
25710
|
+
* current unbroken run into the scope started FROM, so the same run always
|
|
25711
|
+
* re-derives the same key. `resumed` is whether an agent turn in that run
|
|
25712
|
+
* already landed.
|
|
25713
|
+
*/
|
|
25714
|
+
const memoryOf = (trace, run) => {
|
|
25715
|
+
const rest = trace[trace.length - 1];
|
|
25716
|
+
if (rest === void 0 || rest.kind !== "agent") return {
|
|
25717
|
+
key: void 0,
|
|
25718
|
+
resumed: false
|
|
25719
|
+
};
|
|
25720
|
+
const start = scopeRunStart(trace, rest.memoryScope);
|
|
25721
|
+
const token = start <= 0 ? run.startParentHash : trace[start - 1].enteredAt;
|
|
25722
|
+
const resumed = trace.slice(Math.max(start, 0), trace.length - 1).some((s) => s.kind === "agent" && s.memoryScope === rest.memoryScope);
|
|
25723
|
+
return {
|
|
25724
|
+
key: `${rest.memoryScope || ROOT_MEMORY_SCOPE_NAME}#${token.slice(0, 7)}`,
|
|
25725
|
+
resumed
|
|
25726
|
+
};
|
|
25727
|
+
};
|
|
25728
|
+
const hintsOf = (def) => ({
|
|
25729
|
+
...optional("model", def.model),
|
|
25730
|
+
...optional("label", def.label),
|
|
25731
|
+
...optional("file", def.file),
|
|
25732
|
+
...optional("judge", def.judge),
|
|
25733
|
+
...optional("system", def.system),
|
|
25734
|
+
...optional("mode", def.mode),
|
|
25735
|
+
...optional("skills", def.skills),
|
|
25736
|
+
...optional("access", def.access)
|
|
25737
|
+
});
|
|
25738
|
+
const normalizeStatus = (raw) => raw === "A" ? "A" : raw === "D" ? "D" : "M";
|
|
25739
|
+
/** Per-model token totals, highest-cost first (ties broken by model name). */
|
|
25740
|
+
const costByModel = (entries) => {
|
|
25741
|
+
const byModel = /* @__PURE__ */ new Map();
|
|
25742
|
+
for (const entry of entries) byModel.set(entry.model, (byModel.get(entry.model) ?? 0) + entry.cost);
|
|
25743
|
+
return [...byModel.entries()].map(([model, cost]) => ({
|
|
25744
|
+
model,
|
|
25745
|
+
cost
|
|
25746
|
+
})).sort((a, b) => b.cost - a.cost || a.model.localeCompare(b.model));
|
|
25747
|
+
};
|
|
25748
|
+
const costTotals = (entries) => ({
|
|
25749
|
+
total: entries.reduce((sum, entry) => sum + entry.cost, 0),
|
|
25750
|
+
byModel: costByModel(entries)
|
|
25751
|
+
});
|
|
25752
|
+
const setupFor = (config, run, workspace, host, startOverrides = {}) => gen(function* () {
|
|
25753
|
+
const resolved = yield* workflowOf(config, run);
|
|
25754
|
+
const def = resolved.def;
|
|
25755
|
+
const startFlags = run.trace.length === 0 ? yield* StartFlags : {};
|
|
25756
|
+
const { vars, env } = settingsFor(resolved, run.pinnedVars, host.env, startOverrides, startFlags);
|
|
25757
|
+
const skills = yield* resolveScopeSkills(def, vars);
|
|
25758
|
+
const access = yield* resolveScopeAccess(def, skills, vars);
|
|
25759
|
+
const budget = yield* try_({
|
|
25760
|
+
try: () => judgeBudgetBytes(vars),
|
|
25761
|
+
catch: (e) => e instanceof Error ? e : new Error(String(e))
|
|
25762
|
+
});
|
|
25763
|
+
yield* workspace.episodeTrees(run.episode.base, run.episode.commits.map((c) => c.hash));
|
|
25764
|
+
const base = run.episode.base === void 0 ? {
|
|
25765
|
+
hash: EMPTY_TREE$1,
|
|
25766
|
+
tree: treeFromRecord({})
|
|
25767
|
+
} : {
|
|
25768
|
+
hash: run.episode.base,
|
|
25769
|
+
tree: commitTree(workspace, run.episode.base)
|
|
25770
|
+
};
|
|
25771
|
+
const git = yield* GitService;
|
|
25772
|
+
const episode = [];
|
|
25773
|
+
for (const c of run.episode.commits) {
|
|
25774
|
+
const tip = parseCommitMessage(c.message).kept;
|
|
25775
|
+
const exists = tip !== void 0 && isSome(yield* git.readRefOption(`${tip}^{commit}`));
|
|
25776
|
+
episode.push({
|
|
25777
|
+
hash: c.hash,
|
|
25778
|
+
message: c.message,
|
|
25779
|
+
tree: commitTree(workspace, c.hash),
|
|
25780
|
+
...exists ? { kept: {
|
|
25781
|
+
hash: tip,
|
|
25782
|
+
tree: commitTree(workspace, tip)
|
|
25783
|
+
} } : {}
|
|
25784
|
+
});
|
|
25785
|
+
}
|
|
25786
|
+
let pendingMemo;
|
|
25787
|
+
return {
|
|
25788
|
+
resolved,
|
|
25789
|
+
def,
|
|
25790
|
+
vars,
|
|
25791
|
+
env,
|
|
25792
|
+
setup: {
|
|
25793
|
+
def,
|
|
25794
|
+
run,
|
|
25795
|
+
vars,
|
|
25796
|
+
env,
|
|
25797
|
+
budget,
|
|
25798
|
+
skills,
|
|
25799
|
+
access,
|
|
25800
|
+
workspace,
|
|
25801
|
+
base,
|
|
25802
|
+
episode,
|
|
25803
|
+
pendingWorktree: () => pendingMemo ??= workspace.worktreeSync()
|
|
25804
|
+
}
|
|
25805
|
+
};
|
|
25806
|
+
});
|
|
25807
|
+
const pendingChanges = (git, withIndex) => gen(function* () {
|
|
25808
|
+
const worktree = yield* git.changedPaths();
|
|
25809
|
+
if (!withIndex) return worktree;
|
|
25810
|
+
const seen = new Set(worktree.map((e) => e.path));
|
|
25811
|
+
return [...worktree, ...(yield* git.stagedPaths()).filter((e) => !seen.has(e.path))];
|
|
25812
|
+
});
|
|
25813
|
+
/** Resolve the rest at `ref`, or at HEAD when `ref` is `undefined`. */
|
|
25814
|
+
const restAt = (ref, startOverrides = {}) => gen(function* () {
|
|
25815
|
+
const git = yield* GitService;
|
|
25816
|
+
const config = yield* (yield* ConfigService).load;
|
|
25817
|
+
const workspace = yield* Workspace;
|
|
25818
|
+
const host = yield* Host;
|
|
25819
|
+
const run = yield* computeProcessRun(git, config.workflow, ref);
|
|
25820
|
+
const { resolved, def, vars, env, setup } = yield* setupFor(config, run, workspace, host, startOverrides);
|
|
25821
|
+
const outcome = yield* promise(() => replayFor(setup));
|
|
25822
|
+
const pinned = headHasPin(workspace, run);
|
|
25823
|
+
const pin = pinBeatAt(outcome, run, def, pinned);
|
|
25824
|
+
const resting = restingOn(outcome, pin);
|
|
25825
|
+
if (resting === void 0) return yield* fail$4(replayError(outcome) ?? /* @__PURE__ */ new Error("gtd: replay found no rest"));
|
|
25826
|
+
const { reached, step } = resting;
|
|
25827
|
+
const { stepDef, state, memory } = restFacts(pin, step, reached.trace, run);
|
|
25828
|
+
yield* (yield* Narrator).narrate(`rest resolved: ${state} (awaits ${stepDef.actor})`);
|
|
25829
|
+
if (stepDef.mode !== void 0 && def.modes[stepDef.mode] === void 0) return yield* fail$4(/* @__PURE__ */ new Error(`gtd: config: step "${step.name}": mode "${stepDef.mode}" is not a mode this workflow knows (${knownModes(def).join(", ")})`));
|
|
25830
|
+
const changes = ref === void 0 ? (yield* pendingChanges(git, stepDef.approve === true)).map((e) => ({
|
|
25831
|
+
status: normalizeStatus(e.status),
|
|
25832
|
+
path: e.path
|
|
25833
|
+
})) : [];
|
|
25834
|
+
const configNow = config.fingerprint;
|
|
25835
|
+
if (ref === void 0) yield* warnConfigChanges(run, configNow, workspace);
|
|
25836
|
+
return {
|
|
25837
|
+
def,
|
|
25838
|
+
workflowName: resolved.name,
|
|
25839
|
+
state,
|
|
25840
|
+
stepDef,
|
|
25841
|
+
actor: stepDef.actor,
|
|
25842
|
+
pin,
|
|
25843
|
+
pinned,
|
|
25844
|
+
step,
|
|
25845
|
+
trace: reached.trace,
|
|
25846
|
+
run,
|
|
25847
|
+
vars,
|
|
25848
|
+
env,
|
|
25849
|
+
config: configNow,
|
|
25850
|
+
changes,
|
|
25851
|
+
memory: memory.key,
|
|
25852
|
+
memoryResumed: memory.resumed,
|
|
25853
|
+
hints: hintsOf(stepDef),
|
|
25854
|
+
cost: costTotals(run.costEntries),
|
|
25855
|
+
setup
|
|
25856
|
+
};
|
|
25857
|
+
});
|
|
25858
|
+
const currentRest = restAt(void 0);
|
|
25859
|
+
/** The step the process rests at and the trace that reached it — at an unpin, the flow's last step. */
|
|
25860
|
+
const restingOn = (outcome, pin) => {
|
|
25861
|
+
if (outcome.kind === "rest") return {
|
|
25862
|
+
reached: outcome,
|
|
25863
|
+
step: outcome.rest
|
|
25864
|
+
};
|
|
25865
|
+
if (outcome.kind !== "ended" || pin !== "remove") return void 0;
|
|
25866
|
+
const step = outcome.trace.at(-1);
|
|
25867
|
+
return step === void 0 ? void 0 : {
|
|
25868
|
+
reached: outcome,
|
|
25869
|
+
step
|
|
25870
|
+
};
|
|
25871
|
+
};
|
|
25872
|
+
/** A pin or unpin beat has its own script, no step's, and no agent memory. */
|
|
25873
|
+
const restFacts = (pin, step, trace, run) => pin === void 0 ? {
|
|
25874
|
+
stepDef: stepDefOf(step),
|
|
25875
|
+
state: step.name,
|
|
25876
|
+
memory: memoryOf(trace, run)
|
|
25877
|
+
} : {
|
|
25878
|
+
stepDef: pinStepDef(pin),
|
|
25879
|
+
state: PIN_STATES[pin],
|
|
25880
|
+
memory: {
|
|
25881
|
+
key: void 0,
|
|
25882
|
+
resumed: false
|
|
25883
|
+
}
|
|
25884
|
+
};
|
|
25885
|
+
const headHasPin = (workspace, run) => {
|
|
25886
|
+
const head = run.episode.commits.at(-1)?.hash ?? run.episode.base;
|
|
25887
|
+
return head !== void 0 && workspace.treeSync(head).has(".gtd/process.gtdpin");
|
|
25888
|
+
};
|
|
25889
|
+
const underwayAt = (run, step, def) => run.workflow !== void 0 || step.name !== def.initial || step.id.occurrence !== 1;
|
|
25851
25890
|
/**
|
|
25852
|
-
*
|
|
25853
|
-
*
|
|
25854
|
-
* value is knowable when gtd generates it.
|
|
25891
|
+
* A started process pins first — again, should a turn have deleted the pin;
|
|
25892
|
+
* a pinned flow that ended unpins before the closing commit.
|
|
25855
25893
|
*/
|
|
25856
|
-
const
|
|
25857
|
-
|
|
25858
|
-
|
|
25859
|
-
|
|
25860
|
-
|
|
25861
|
-
|
|
25862
|
-
|
|
25863
|
-
|
|
25864
|
-
|
|
25865
|
-
|
|
25866
|
-
|
|
25867
|
-
`"$(git log -1 --format=%s ${shellQuote(to)})"`,
|
|
25868
|
-
shellQuote(state)
|
|
25869
|
-
]);
|
|
25870
|
-
//#endregion
|
|
25871
|
-
//#region src/emit/ModeContradiction.ts
|
|
25894
|
+
const pinBeatAt = (outcome, run, def, pinned) => {
|
|
25895
|
+
if (flowEnded(outcome, run, def)) return pinned || run.pinCommitted ? "remove" : void 0;
|
|
25896
|
+
if (outcome.kind !== "rest" || pinned) return void 0;
|
|
25897
|
+
return underwayAt(run, outcome.rest, def) ? "add" : void 0;
|
|
25898
|
+
};
|
|
25899
|
+
const flowEnded = (outcome, run, def) => outcome.kind === "ended" || outcome.kind === "rest" && run.workflow === void 0 && outcome.rest.name === def.initial && underwayAt(run, outcome.rest, def);
|
|
25900
|
+
const pinStepDef = (pin) => ({
|
|
25901
|
+
actor: "check",
|
|
25902
|
+
kind: "script",
|
|
25903
|
+
content: pin === "add" ? pinScript(PIN_PATH) : unpinScript(PIN_PATH)
|
|
25904
|
+
});
|
|
25872
25905
|
/**
|
|
25873
|
-
*
|
|
25874
|
-
*
|
|
25875
|
-
*
|
|
25876
|
-
* thrash on the document) and the human (which mode, and the exact rendered
|
|
25877
|
-
* `format:` command, to fix `gtd.config.ts` without reproducing the failure).
|
|
25906
|
+
* Whether `gtd start <name>` can start a process here: the workflow must
|
|
25907
|
+
* reach a step from the tree the opening commit would capture. That step is
|
|
25908
|
+
* what the opening commit's subject names.
|
|
25878
25909
|
*/
|
|
25879
|
-
const
|
|
25910
|
+
const startRefusal = (rest, resolved, startVars) => gen(function* () {
|
|
25911
|
+
const host = yield* Host;
|
|
25912
|
+
const { vars, env } = settingsFor(resolved, {}, host.env, startVars, yield* StartFlags);
|
|
25913
|
+
const skills = yield* resolveScopeSkills(resolved.def, vars);
|
|
25914
|
+
const access = yield* resolveScopeAccess(resolved.def, skills, vars);
|
|
25915
|
+
const workspace = rest.setup.workspace;
|
|
25916
|
+
const outcome = yield* promise(() => replay({
|
|
25917
|
+
flow: resolved.def.flow,
|
|
25918
|
+
episode: {
|
|
25919
|
+
base: {
|
|
25920
|
+
hash: "",
|
|
25921
|
+
tree: pendingTree(workspace, rest.setup.pendingWorktree(), void 0)
|
|
25922
|
+
},
|
|
25923
|
+
commits: []
|
|
25924
|
+
},
|
|
25925
|
+
vars,
|
|
25926
|
+
env,
|
|
25927
|
+
start: "",
|
|
25928
|
+
budgetBytes: rest.setup.budget,
|
|
25929
|
+
skills,
|
|
25930
|
+
configuredSkills: resolved.def.configuredSkills,
|
|
25931
|
+
access,
|
|
25932
|
+
configuredAccess: resolved.def.configuredAccess
|
|
25933
|
+
}));
|
|
25934
|
+
if (outcome.kind === "rest") return { firstStep: outcome.rest.name };
|
|
25935
|
+
if (outcome.kind === "refused") return { refusal: outcome.message };
|
|
25936
|
+
const why = outcome.kind === "ended" ? "the flow reaches no step" : outcome.message;
|
|
25937
|
+
return { refusal: `workflow "${resolved.name}" cannot start — ${why}` };
|
|
25938
|
+
});
|
|
25939
|
+
/** The review window's diff base at the rest. */
|
|
25940
|
+
const reviewBaseFor = (rest) => rest.step.request.options.base ?? rest.run.diffBase;
|
|
25941
|
+
const renderRest = (rest) => succeed$1({
|
|
25942
|
+
state: rest.state,
|
|
25943
|
+
actor: rest.actor,
|
|
25944
|
+
kind: rest.stepDef.kind,
|
|
25945
|
+
content: rest.stepDef.content,
|
|
25946
|
+
...rest.hints,
|
|
25947
|
+
...rest.memory !== void 0 ? { memory: rest.memory } : {},
|
|
25948
|
+
memoryResumed: rest.memoryResumed,
|
|
25949
|
+
...rest.pin !== void 0 ? { pin: rest.pin } : {}
|
|
25950
|
+
});
|
|
25880
25951
|
/**
|
|
25881
|
-
*
|
|
25882
|
-
*
|
|
25883
|
-
* mode whose `format:` contradicts its own `validate:` without ever touching
|
|
25884
|
-
* the real steering file. `printf '%s'` over a `shellQuote`d sample, never a
|
|
25885
|
-
* heredoc, since no delimiter chosen ahead of time can collide with the
|
|
25886
|
-
* sample's own bytes. One bash fragment with no blank lines (so
|
|
25887
|
-
* `src/emit/Emit.ts`'s `assembleScript` never splits it from a sibling step), meant
|
|
25888
|
-
* to run BEFORE `[ -f <file> ] || exit 0` in the emitted validate script so it
|
|
25889
|
-
* still fires at a first-write beat where the real steering file doesn't
|
|
25890
|
-
* exist yet.
|
|
25952
|
+
* Derived, restart-proof stall detection: the tree is clean, HEAD is an empty
|
|
25953
|
+
* attempt at this very agent rest, and another dispatch would repeat it.
|
|
25891
25954
|
*/
|
|
25892
|
-
const
|
|
25893
|
-
|
|
25894
|
-
|
|
25895
|
-
|
|
25896
|
-
|
|
25897
|
-
|
|
25898
|
-
|
|
25899
|
-
|
|
25900
|
-
|
|
25901
|
-
|
|
25902
|
-
|
|
25903
|
-
`
|
|
25904
|
-
|
|
25905
|
-
|
|
25906
|
-
|
|
25955
|
+
const stalledAt = (rest) => rest.changes.length === 0 && rest.stepDef.kind === "prompt" && rest.stepDef.allowEmpty !== true && rest.run.headTurn?.state === rest.state && rest.run.headTurn.actor === rest.actor && !rest.run.headTurn.step && rest.run.headTurn.empty;
|
|
25956
|
+
/** No process is underway: the default workflow's first step, first visit — a dirty tree there is a turn not yet landed, not a process. */
|
|
25957
|
+
const noProcessUnderway = (rest) => rest.pin === void 0 && !underwayAt(rest.run, rest.step, rest.def);
|
|
25958
|
+
/** `idle` means exactly one thing: no process underway, clean tree. */
|
|
25959
|
+
const restIsIdle = (rest) => noProcessUnderway(rest) && rest.changes.length === 0;
|
|
25960
|
+
const isHumanReviewGate = (def) => def.actor === "human" && def.mode === "review";
|
|
25961
|
+
const approvalRefusal = (rest, approver) => {
|
|
25962
|
+
if (rest.stepDef.approve !== true) return void 0;
|
|
25963
|
+
const id = rest.step.id;
|
|
25964
|
+
if (rest.changes.length > 0) return {
|
|
25965
|
+
kind: "refusal",
|
|
25966
|
+
message: `gtd land: approval-gate: ${formatStepId(id)} approves exactly the judged diff — revert your edits and run \`gtd land\` on a clean tree to approve, or run \`gtd abandon\``
|
|
25967
|
+
};
|
|
25968
|
+
if (isNone(approver)) return {
|
|
25969
|
+
kind: "refusal",
|
|
25970
|
+
message: `gtd land: approval-gate: ${formatStepId(id)} records who approved it, but git has no author identity — set user.name and user.email`
|
|
25971
|
+
};
|
|
25972
|
+
if (!isRecordableApproval({
|
|
25973
|
+
step: id,
|
|
25974
|
+
...approver.value
|
|
25975
|
+
})) return {
|
|
25976
|
+
kind: "refusal",
|
|
25977
|
+
message: `gtd land: approval-gate: ${formatStepId(id)} cannot record ${approver.value.name} <${approver.value.email}> as approver — it would not read back unchanged from its Gtd-Approved trailer (a control character, for example)`
|
|
25978
|
+
};
|
|
25907
25979
|
};
|
|
25908
|
-
/**
|
|
25909
|
-
|
|
25910
|
-
|
|
25911
|
-
|
|
25912
|
-
|
|
25913
|
-
|
|
25914
|
-
|
|
25915
|
-
|
|
25980
|
+
/** The landing a clean tree decides before any replay, if it decides one. */
|
|
25981
|
+
const cleanLanding = (rest) => {
|
|
25982
|
+
if (rest.changes.length > 0) return void 0;
|
|
25983
|
+
const def = rest.stepDef;
|
|
25984
|
+
if (def.kind === "prompt" && def.allowEmpty !== true) return {
|
|
25985
|
+
kind: "attempt",
|
|
25986
|
+
subject: formatSubject(rest.actor, rest.state)
|
|
25987
|
+
};
|
|
25988
|
+
if (rest.actor === "human" && def.acceptClean !== true) return {
|
|
25989
|
+
kind: "noop",
|
|
25990
|
+
settled: false
|
|
25991
|
+
};
|
|
25992
|
+
};
|
|
25993
|
+
/** At the human review gate the pending tree lands with every tick cleared, as the landing script will. */
|
|
25994
|
+
const reviewGateRewrite = (def) => {
|
|
25995
|
+
const reviewFormat = steeringFormatFor("review");
|
|
25996
|
+
if (!isHumanReviewGate(def) || def.file === void 0 || reviewFormat === void 0) return;
|
|
25997
|
+
return {
|
|
25998
|
+
path: def.file,
|
|
25999
|
+
apply: (content) => reviewFormat.clearTicks(content)
|
|
26000
|
+
};
|
|
26001
|
+
};
|
|
26002
|
+
const multilineRefusal = (vars) => {
|
|
26003
|
+
const multiline = multilineSetting(vars);
|
|
26004
|
+
return multiline === void 0 ? void 0 : {
|
|
26005
|
+
kind: "refusal",
|
|
26006
|
+
message: `gtd: process setting "${multiline}" spans several lines — a process setting is pinned in a commit trailer and must be a single line`
|
|
26007
|
+
};
|
|
26008
|
+
};
|
|
26009
|
+
const landingTarget = (outcome, rest) => {
|
|
26010
|
+
if (outcome.kind === "refused") return succeed$1({
|
|
26011
|
+
kind: "refusal",
|
|
26012
|
+
message: outcome.message
|
|
26013
|
+
});
|
|
26014
|
+
if (outcome.kind === "divergence" || outcome.kind === "failed") return fail$4(new Error(outcome.message));
|
|
26015
|
+
if (outcome.kind !== "rest") return succeed$1(rest.def.initial);
|
|
26016
|
+
const reserved = initialStepRefusal(rest.run.workflow, outcome.rest.name, rest.def.initial);
|
|
26017
|
+
return succeed$1(reserved === void 0 ? outcome.rest.name : reserved);
|
|
25916
26018
|
};
|
|
25917
|
-
//#endregion
|
|
25918
|
-
//#region src/emit/SteeringMode.ts
|
|
25919
26019
|
/**
|
|
25920
|
-
*
|
|
25921
|
-
*
|
|
25922
|
-
* that
|
|
25923
|
-
* other command. Each arm returns directly — a flat sequence, not nested
|
|
25924
|
-
* ternaries — so the four outcomes stay each their own case to read, not a
|
|
25925
|
-
* boolean expression to re-derive.
|
|
26020
|
+
* A step commit entering the default's first step is what ends an episode, so
|
|
26021
|
+
* a named workflow landing there would read back as finished. Its opening
|
|
26022
|
+
* commit may name that step: a bare opening is never read as a boundary.
|
|
25926
26023
|
*/
|
|
25927
|
-
const
|
|
25928
|
-
|
|
25929
|
-
|
|
25930
|
-
|
|
25931
|
-
|
|
26024
|
+
const initialStepRefusal = (workflow, step, initial) => workflow !== void 0 && step === initial ? {
|
|
26025
|
+
kind: "refusal",
|
|
26026
|
+
message: `gtd: workflow "${workflow}" reaches step "${step}", the default workflow's first step — that is where a finished process waits, so a started process must never land there; rename the step`
|
|
26027
|
+
} : void 0;
|
|
26028
|
+
const closingTrailers = (branch) => ({
|
|
26029
|
+
version: GTD_VERSION,
|
|
26030
|
+
...isSome(branch) ? { branch: branch.value } : {}
|
|
26031
|
+
});
|
|
26032
|
+
const isSettledNoop = (rest, to) => rest.changes.length === 0 && rest.stepDef.kind === "script" && to === rest.state;
|
|
26033
|
+
const keptTurn = (rest) => rest.stepDef.keep === true && rest.changes.length > 0 ? { keepTurn: keptRef(rest.run.startHash, rest.state) } : {};
|
|
26034
|
+
const approvedTrailer = (rest, approver) => isSome(approver) ? { approved: {
|
|
26035
|
+
step: rest.step.id,
|
|
26036
|
+
...approver.value
|
|
26037
|
+
} } : {};
|
|
26038
|
+
const landingTo = (rest, to, branch, approver, marks) => {
|
|
26039
|
+
if (isSettledNoop(rest, to)) return {
|
|
26040
|
+
kind: "noop",
|
|
26041
|
+
settled: true
|
|
25932
26042
|
};
|
|
25933
|
-
|
|
25934
|
-
|
|
25935
|
-
|
|
25936
|
-
|
|
26043
|
+
const underway = !noProcessUnderway(rest);
|
|
26044
|
+
const starts = !underway && to !== rest.def.initial;
|
|
26045
|
+
const refused = starts ? multilineRefusal(rest.vars) : void 0;
|
|
26046
|
+
if (refused !== void 0) return refused;
|
|
26047
|
+
const { finishes, landsAt } = endingOf(rest, to, underway);
|
|
26048
|
+
return {
|
|
26049
|
+
kind: "commit",
|
|
26050
|
+
to: landsAt,
|
|
26051
|
+
spec: {
|
|
26052
|
+
actor: rest.actor,
|
|
26053
|
+
from: rest.state,
|
|
26054
|
+
to: landsAt,
|
|
26055
|
+
step: rest.step.id,
|
|
26056
|
+
...marks.length > 0 && { marks },
|
|
26057
|
+
...approvedTrailer(rest, approver),
|
|
26058
|
+
...startTrailers(rest, starts),
|
|
26059
|
+
...finishes ? closingTrailers(branch) : {}
|
|
26060
|
+
},
|
|
26061
|
+
...finishes ? { keepHistory: processHistoryRef(rest.run.startHash) } : {},
|
|
26062
|
+
...keptTurn(rest)
|
|
25937
26063
|
};
|
|
26064
|
+
};
|
|
26065
|
+
const endingOf = (rest, to, underway) => {
|
|
26066
|
+
const ends = underway && to === rest.def.initial;
|
|
25938
26067
|
return {
|
|
25939
|
-
|
|
25940
|
-
|
|
26068
|
+
finishes: ends && !rest.pinned,
|
|
26069
|
+
landsAt: ends && rest.pinned ? PIN_STATES.remove : to
|
|
25941
26070
|
};
|
|
25942
26071
|
};
|
|
25943
|
-
/**
|
|
25944
|
-
|
|
25945
|
-
|
|
25946
|
-
|
|
25947
|
-
|
|
25948
|
-
|
|
25949
|
-
|
|
25950
|
-
|
|
25951
|
-
|
|
25952
|
-
|
|
25953
|
-
|
|
25954
|
-
|
|
25955
|
-
|
|
25956
|
-
|
|
25957
|
-
|
|
25958
|
-
|
|
25959
|
-
|
|
25960
|
-
|
|
25961
|
-
const format = steeringFormatFor(mode);
|
|
25962
|
-
if (declared === void 0 && format === void 0) return {
|
|
25963
|
-
kind: "unknown",
|
|
25964
|
-
message: def !== void 0 ? `state "${state}": mode "${mode}" is not defined by the active workflow (known modes: ${knownModes(def).join(", ")})` : `mode "${mode}" is not a built-in steering format`
|
|
26072
|
+
/** A pin or unpin commit carries the pin alone; the unpin commit closes the process. */
|
|
26073
|
+
const pinLanding = (rest, pin, branch) => {
|
|
26074
|
+
const change = rest.changes.find((c) => c.path === PIN_PATH)?.status;
|
|
26075
|
+
return pin === "add" ? pinCommit(rest, change) : unpinCommit(rest, change, branch);
|
|
26076
|
+
};
|
|
26077
|
+
const pinCommit = (rest, change) => {
|
|
26078
|
+
if (change === void 0 || change === "D") return pinRefusal("add");
|
|
26079
|
+
const to = rest.step.name;
|
|
26080
|
+
return {
|
|
26081
|
+
kind: "commit",
|
|
26082
|
+
to,
|
|
26083
|
+
spec: {
|
|
26084
|
+
actor: rest.actor,
|
|
26085
|
+
from: rest.state,
|
|
26086
|
+
to,
|
|
26087
|
+
pin: "add"
|
|
26088
|
+
},
|
|
26089
|
+
only: PIN_PATH
|
|
25965
26090
|
};
|
|
25966
|
-
|
|
25967
|
-
|
|
25968
|
-
|
|
25969
|
-
|
|
25970
|
-
kind: "builtin",
|
|
25971
|
-
format
|
|
25972
|
-
} : void 0;
|
|
26091
|
+
};
|
|
26092
|
+
const unpinCommit = (rest, change, branch) => {
|
|
26093
|
+
if (change !== "D" && rest.pinned) return pinRefusal("remove");
|
|
26094
|
+
const to = rest.def.initial;
|
|
25973
26095
|
return {
|
|
25974
|
-
kind: "
|
|
25975
|
-
|
|
25976
|
-
|
|
25977
|
-
|
|
25978
|
-
|
|
25979
|
-
|
|
26096
|
+
kind: "commit",
|
|
26097
|
+
to,
|
|
26098
|
+
spec: {
|
|
26099
|
+
actor: rest.actor,
|
|
26100
|
+
from: rest.state,
|
|
26101
|
+
to,
|
|
26102
|
+
pin: "remove",
|
|
26103
|
+
...closingTrailers(branch)
|
|
26104
|
+
},
|
|
26105
|
+
keepHistory: processHistoryRef(rest.run.startHash),
|
|
26106
|
+
...rest.pinned ? { only: PIN_PATH } : {}
|
|
25980
26107
|
};
|
|
25981
26108
|
};
|
|
25982
|
-
|
|
25983
|
-
|
|
26109
|
+
const pinRefusal = (pin) => ({
|
|
26110
|
+
kind: "refusal",
|
|
26111
|
+
message: pin === "add" ? `gtd land: ${PIN_PATH} is missing — run the pin beat's script (\`gtd next --json=content\`); if its \`gtd bundle\` fails, fix what it reports, or run \`gtd abandon\`` : `gtd land: ${PIN_PATH} is still there — run the unpin beat's script (\`gtd next --json=content\`), then land`
|
|
26112
|
+
});
|
|
26113
|
+
const startTrailers = (rest, starts) => starts ? {
|
|
26114
|
+
vars: rest.vars,
|
|
26115
|
+
config: rest.config
|
|
26116
|
+
} : {};
|
|
26117
|
+
const outcomeMarks = (outcome) => outcome.kind === "rest" || outcome.kind === "ended" ? outcome.marks : [];
|
|
25984
26118
|
/**
|
|
25985
|
-
*
|
|
25986
|
-
*
|
|
25987
|
-
*
|
|
25988
|
-
* captured-output path. Each command guards on its OWN leading word — a
|
|
25989
|
-
* guard naming the wrong binary is worse than none.
|
|
26119
|
+
* What landing the pending turn at `rest` does — a pure decision from a
|
|
26120
|
+
* replay with the working tree as the pending turn. The target step is
|
|
26121
|
+
* computed by replaying, never matched.
|
|
25990
26122
|
*/
|
|
25991
|
-
const
|
|
25992
|
-
|
|
25993
|
-
|
|
25994
|
-
|
|
25995
|
-
|
|
25996
|
-
|
|
25997
|
-
|
|
25998
|
-
|
|
25999
|
-
|
|
26000
|
-
|
|
26001
|
-
|
|
26123
|
+
const decideLanding = (rest, verdicts, branch, approver) => gen(function* () {
|
|
26124
|
+
if (rest.pin !== void 0) return pinLanding(rest, rest.pin, branch);
|
|
26125
|
+
const unapproved = approvalRefusal(rest, approver);
|
|
26126
|
+
if (unapproved !== void 0) return unapproved;
|
|
26127
|
+
const early = cleanLanding(rest);
|
|
26128
|
+
if (early !== void 0) return early;
|
|
26129
|
+
if (rest.stepDef.kind === "prompt" && rest.stepDef.access?.write != null) {
|
|
26130
|
+
const message = accessRefusal(rest.changes, rest.stepDef.access.write);
|
|
26131
|
+
if (message !== void 0) return {
|
|
26132
|
+
kind: "refusal",
|
|
26133
|
+
message
|
|
26134
|
+
};
|
|
26135
|
+
}
|
|
26136
|
+
const outcome = yield* promise(() => replayFor(rest.setup, {
|
|
26137
|
+
tree: pendingTree(rest.setup.workspace, rest.setup.pendingWorktree(), reviewGateRewrite(rest.stepDef)),
|
|
26138
|
+
...verdicts !== void 0 ? { verdicts } : {}
|
|
26139
|
+
}));
|
|
26140
|
+
const to = yield* landingTarget(outcome, rest);
|
|
26141
|
+
if (typeof to !== "string") return to;
|
|
26142
|
+
return landingTo(rest, to, branch, approver, outcomeMarks(outcome));
|
|
26143
|
+
});
|
|
26144
|
+
/** Where landing the pending turn would leave the process, or `undefined` when it would land nothing. */
|
|
26145
|
+
const previewLanding = (rest) => decideLanding(rest, void 0, none(), none()).pipe(map((landing) => landing.kind === "commit" ? landing.to : void 0), catch_(() => succeed$1(void 0)));
|
|
26146
|
+
/** Every fact the pure landing planner needs, read once: the rest, its steering file, and the landing decision. */
|
|
26147
|
+
const snapshotFromRest = (rest, verdicts) => gen(function* () {
|
|
26148
|
+
const git = yield* GitService;
|
|
26149
|
+
const branch = yield* git.currentBranch();
|
|
26150
|
+
const approver = rest.stepDef.approve === true ? yield* git.authorIdent() : none();
|
|
26151
|
+
const landing = yield* decideLanding(rest, verdicts, branch, approver);
|
|
26152
|
+
return {
|
|
26153
|
+
stepDef: rest.stepDef,
|
|
26154
|
+
state: rest.state,
|
|
26155
|
+
actor: rest.actor,
|
|
26156
|
+
changes: rest.changes,
|
|
26157
|
+
file: rest.hints.file,
|
|
26158
|
+
landing
|
|
26159
|
+
};
|
|
26160
|
+
});
|
|
26161
|
+
const humanCommitsOf = (trace, entryCommit) => trace.filter((entry) => entry.actor === "human" && entry.hash !== entryCommit).map((entry) => ({
|
|
26162
|
+
hash: entry.hash,
|
|
26163
|
+
state: entry.state
|
|
26164
|
+
}));
|
|
26165
|
+
const summaryInput = (run, settings) => {
|
|
26166
|
+
const entryCommit = run.trace[0].hash;
|
|
26167
|
+
const { total, byModel } = costTotals(run.costEntries);
|
|
26168
|
+
return {
|
|
26169
|
+
entryCommit,
|
|
26170
|
+
processBase: run.startParentHash,
|
|
26171
|
+
processTip: run.trace[run.trace.length - 1].hash,
|
|
26172
|
+
humanCommits: humanCommitsOf(run.trace, entryCommit),
|
|
26173
|
+
processCost: total,
|
|
26174
|
+
processCostByModel: byModel,
|
|
26175
|
+
marks: run.marks,
|
|
26176
|
+
...settings
|
|
26177
|
+
};
|
|
26002
26178
|
};
|
|
26003
|
-
|
|
26004
|
-
|
|
26005
|
-
|
|
26006
|
-
|
|
26007
|
-
|
|
26008
|
-
|
|
26009
|
-
|
|
26010
|
-
|
|
26011
|
-
|
|
26012
|
-
|
|
26013
|
-
|
|
26014
|
-
|
|
26179
|
+
const summaryFor = (run) => gen(function* () {
|
|
26180
|
+
const config = yield* (yield* ConfigService).load;
|
|
26181
|
+
const host = yield* Host;
|
|
26182
|
+
const resolved = yield* workflowOf(config, run);
|
|
26183
|
+
const summary = resolved.def.summary;
|
|
26184
|
+
if (summary === void 0 || run.trace.length === 0) return { prompt: void 0 };
|
|
26185
|
+
return { prompt: summary(summaryInput(run, settingsFor(resolved, run.pinnedVars, host.env, {}, {}))) };
|
|
26186
|
+
});
|
|
26187
|
+
/** Repository-relative paths of the config files today's config and the process's own record name; one outside the repository cannot be read at a ref, so it is left out. */
|
|
26188
|
+
const inRepoConfigPaths = (root, files, recorded) => {
|
|
26189
|
+
const paths = new Set(Object.keys(recorded));
|
|
26190
|
+
for (const file of files) paths.add(relative(root, resolve(root, file)).split(sep).join("/"));
|
|
26191
|
+
return [...paths].filter((p) => p !== "" && !p.startsWith("..") && !isAbsolute(p)).sort();
|
|
26015
26192
|
};
|
|
26016
|
-
|
|
26017
|
-
const
|
|
26018
|
-
|
|
26019
|
-
*
|
|
26020
|
-
|
|
26021
|
-
* built-in validator runs the round-trip against that format's own canonical
|
|
26022
|
-
* sample; an external validator prints a one-line skip notice instead, since
|
|
26023
|
-
* silence there would read as a clean bill of health; a format-only mode with
|
|
26024
|
-
* neither has nothing to round-trip either.
|
|
26025
|
-
*/
|
|
26026
|
-
const modeContradictionSteps = (resolved) => gen(function* () {
|
|
26027
|
-
if (resolved.formatCommand === void 0) return [];
|
|
26028
|
-
const { capabilities } = resolved;
|
|
26029
|
-
if (capabilities.format !== void 0 && capabilities.liveValidate !== void 0) {
|
|
26030
|
-
const { scratchDir } = yield* Host;
|
|
26031
|
-
const samplePath = scratchSamplePath(scratchDir, resolved.mode);
|
|
26032
|
-
const formatCommand = withFileVar(resolved.formatCommand, samplePath);
|
|
26033
|
-
return [{
|
|
26034
|
-
kind: "command",
|
|
26035
|
-
command: buildModeContradictionCheck({
|
|
26036
|
-
mode: resolved.mode,
|
|
26037
|
-
samplePath,
|
|
26038
|
-
sample: capabilities.format.sample,
|
|
26039
|
-
formatCommand
|
|
26040
|
-
})
|
|
26041
|
-
}];
|
|
26042
|
-
}
|
|
26043
|
-
if (capabilities.externalValidate === true) return [{
|
|
26044
|
-
kind: "command",
|
|
26045
|
-
command: modeContradictionSkipNotice(resolved.mode)
|
|
26046
|
-
}];
|
|
26047
|
-
return [];
|
|
26193
|
+
const configRefusal = (command, tip, paths, reason = "A history ref replays under today's config, so it is unreadable after the config changes") => /* @__PURE__ */ new Error(`${command}: refused — config differs at ${tip.slice(0, 7)}: ${paths.join(", ")}. ${reason}`);
|
|
26194
|
+
const differingConfigPaths = (git, workspace, root, config, run, hash) => gen(function* () {
|
|
26195
|
+
const differing = [];
|
|
26196
|
+
for (const path of inRepoConfigPaths(root, Object.keys(config.fingerprint), run.pinnedConfig?.files ?? {})) if ((yield* orElseSucceed(git.readFileAtRef(hash, path), () => void 0)) !== workspace.readSync(path)) differing.push(path);
|
|
26197
|
+
return differing;
|
|
26048
26198
|
});
|
|
26199
|
+
const notClosedError = (hash, outcome) => outcome.kind === "rest" ? /* @__PURE__ */ new Error(`gtd history: ${hash.slice(0, 7)} does not close a process under today's config`) : replayError(outcome) ?? /* @__PURE__ */ new Error("gtd history: replay failed");
|
|
26200
|
+
const historySteps = (run) => {
|
|
26201
|
+
const messages = new Map(run.episode.commits.map((c) => [c.hash, c.message]));
|
|
26202
|
+
return run.trace.map((entry) => {
|
|
26203
|
+
const message = parseCommitMessage(messages.get(entry.hash) ?? "");
|
|
26204
|
+
return {
|
|
26205
|
+
hash: entry.hash,
|
|
26206
|
+
subject: (messages.get(entry.hash) ?? "").split("\n")[0] ?? "",
|
|
26207
|
+
actor: entry.actor,
|
|
26208
|
+
from: message.parsed?.from,
|
|
26209
|
+
to: entry.state,
|
|
26210
|
+
verdicts: message.judge,
|
|
26211
|
+
...optional("approval", approvalEntryOf(entry.hash, message)[0])
|
|
26212
|
+
};
|
|
26213
|
+
});
|
|
26214
|
+
};
|
|
26215
|
+
const versionWarningFor = (recorded) => recorded === GTD_VERSION ? void 0 : `gtd: warning: this history was recorded by ${recorded === void 0 ? "an unknown gtd version" : `gtd ${recorded}`} and is replayed by gtd ${GTD_VERSION} — the engine the workflow runs on may have changed since`;
|
|
26049
26216
|
/**
|
|
26050
|
-
*
|
|
26051
|
-
*
|
|
26052
|
-
*
|
|
26053
|
-
* runs this script itself — `resolveMode`+`validateScriptFor` only render
|
|
26054
|
-
* text for a driver to execute (`gtd renders; the driver executes`).
|
|
26217
|
+
* Replays the closed run at `tip` under today's config. The workflow is not
|
|
26218
|
+
* fingerprinted for a version change: the engine under it moves with every
|
|
26219
|
+
* upgrade, and the version warning is the only signal.
|
|
26055
26220
|
*/
|
|
26056
|
-
const
|
|
26057
|
-
|
|
26058
|
-
|
|
26059
|
-
|
|
26060
|
-
|
|
26061
|
-
|
|
26221
|
+
const historyTrail = (tip) => gen(function* () {
|
|
26222
|
+
const git = yield* GitService;
|
|
26223
|
+
const config = yield* (yield* ConfigService).load;
|
|
26224
|
+
const hash = yield* git.resolveRef(tip);
|
|
26225
|
+
const subject = yield* git.lastCommitSubject(hash);
|
|
26226
|
+
const run = yield* runAtTip(git, config.workflow, {
|
|
26227
|
+
hash,
|
|
26228
|
+
subject
|
|
26229
|
+
});
|
|
26230
|
+
const { outcome, recorded, versionWarning } = yield* replayFinishedProcess(config, run, hash, {
|
|
26231
|
+
command: "gtd history",
|
|
26232
|
+
notEnded: (o) => notClosedError(hash, o)
|
|
26233
|
+
});
|
|
26234
|
+
return {
|
|
26235
|
+
tip: hash,
|
|
26236
|
+
steps: historySteps(run),
|
|
26237
|
+
ended: outcome.via,
|
|
26238
|
+
version: {
|
|
26239
|
+
recorded,
|
|
26240
|
+
running: GTD_VERSION
|
|
26062
26241
|
},
|
|
26063
|
-
...
|
|
26064
|
-
|
|
26242
|
+
...versionWarning !== void 0 ? { versionWarning } : {}
|
|
26243
|
+
};
|
|
26244
|
+
});
|
|
26245
|
+
/** Refuses on config drift before replaying, so a stale config never runs the flow. */
|
|
26246
|
+
const replayFinishedProcess = (config, run, hash, how) => gen(function* () {
|
|
26247
|
+
const git = yield* GitService;
|
|
26248
|
+
const workspace = yield* Workspace;
|
|
26249
|
+
const host = yield* Host;
|
|
26250
|
+
const recorded = parseCommitMessage(run.episode.commits.find((c) => c.hash === hash)?.message ?? "").version;
|
|
26251
|
+
const versionWarning = versionWarningFor(recorded);
|
|
26252
|
+
const withWarning = (err) => versionWarning === void 0 ? err : /* @__PURE__ */ new Error(`${err.message}\n${versionWarning}`);
|
|
26253
|
+
const differing = yield* differingConfigPaths(git, workspace, host.root, config, run, hash);
|
|
26254
|
+
if (differing.length > 0) return yield* fail$4(withWarning(configRefusal(how.command, hash, differing, how.configReason)));
|
|
26255
|
+
const { setup } = yield* setupFor(config, run, workspace, host);
|
|
26256
|
+
const outcome = yield* promise(() => replayFor(setup));
|
|
26257
|
+
if (outcome.kind !== "ended") return yield* fail$4(withWarning(how.notEnded(outcome)));
|
|
26258
|
+
return {
|
|
26259
|
+
outcome,
|
|
26260
|
+
recorded,
|
|
26261
|
+
...versionWarning !== void 0 ? { versionWarning } : {}
|
|
26262
|
+
};
|
|
26065
26263
|
});
|
|
26066
26264
|
//#endregion
|
|
26067
26265
|
//#region src/cli/ToolCommands.ts
|
|
@@ -26118,7 +26316,7 @@ const runStraysCommand = (command, json, out) => gen(function* () {
|
|
|
26118
26316
|
const narrator = yield* Narrator;
|
|
26119
26317
|
const base = yield* resolveSide(git, command.base);
|
|
26120
26318
|
const tip = yield* resolveSide(git, command.tip);
|
|
26121
|
-
const touched = yield* git.diffPaths(base, tip);
|
|
26319
|
+
const touched = (yield* git.diffPaths(base, tip)).filter((path) => path !== PIN_PATH);
|
|
26122
26320
|
const strays = strayPaths(touched, command.globs);
|
|
26123
26321
|
const total = touched.length;
|
|
26124
26322
|
if (json.kind === "select") yield* writeSelection(out, strays, json.path).pipe(catchIf((error) => error instanceof SelectorUsageError && /^\d+$/.test(json.path), () => void_));
|
|
@@ -27127,6 +27325,10 @@ const runAbandonCommand = (json, out) => gen(function* () {
|
|
|
27127
27325
|
kind: "gitWrite",
|
|
27128
27326
|
command: mixedResetTo(run.startParentHash)
|
|
27129
27327
|
},
|
|
27328
|
+
{
|
|
27329
|
+
kind: "gitWrite",
|
|
27330
|
+
command: unpinScript(PIN_PATH)
|
|
27331
|
+
},
|
|
27130
27332
|
{
|
|
27131
27333
|
kind: "outcome",
|
|
27132
27334
|
command: abandonedOutcome(restState, run.startParentHash, initial)
|
|
@@ -27284,7 +27486,7 @@ const runCodeThreadsCheckCommand = () => gen(function* () {
|
|
|
27284
27486
|
if (rest.run.trace.length === 0) return;
|
|
27285
27487
|
const git = yield* GitService;
|
|
27286
27488
|
const workspace = yield* Workspace;
|
|
27287
|
-
const lines = (yield* git.changedPaths(rest.run.diffBase)).filter((c) => c.status !== "D").flatMap(({ path }) => {
|
|
27489
|
+
const lines = (yield* git.changedPaths(rest.run.diffBase)).filter((c) => c.status !== "D" && c.path !== ".gtd/process.gtdpin").flatMap(({ path }) => {
|
|
27288
27490
|
const { threads, findings } = parseCodeThreads(path, workspace.atPath(path) ?? "");
|
|
27289
27491
|
return [...threads.filter((t) => t.waitingOn === "human").map((t) => `${path}:${t.line + 1}: ${t.entries[0].text}`), ...findings.map((f) => `${path}:${(f.line ?? 0) + 1}: ${f.message}`)];
|
|
27290
27492
|
});
|
|
@@ -28865,6 +29067,17 @@ var InMemRepo = class InMemRepo {
|
|
|
28865
29067
|
this.index = new Map(this.worktree);
|
|
28866
29068
|
this.resetHard();
|
|
28867
29069
|
}
|
|
29070
|
+
/** Twin of `GitScript.commitPath`: commits `path` as the worktree has it, every other staged change left staged. */
|
|
29071
|
+
commitPath(path, message) {
|
|
29072
|
+
if (!this.worktree.has(path) && !this.headTree().has(path) && !this.index.has(path)) return `error: pathspec '${path}' did not match any file(s) known to git`;
|
|
29073
|
+
const staged = new Map(this.index);
|
|
29074
|
+
this.index = new Map(this.headTree());
|
|
29075
|
+
this.stage(path);
|
|
29076
|
+
const failure = this.commitAsIs(message);
|
|
29077
|
+
this.index = staged;
|
|
29078
|
+
this.stage(path);
|
|
29079
|
+
return failure;
|
|
29080
|
+
}
|
|
28868
29081
|
commitAllWithPrefix(prefix) {
|
|
28869
29082
|
this.index = new Map(this.worktree);
|
|
28870
29083
|
return this.commitAsIs(prefix);
|
|
@@ -29631,6 +29844,16 @@ const recognizeGitBuilders = (repo, block) => {
|
|
|
29631
29844
|
}
|
|
29632
29845
|
const conditionLine = conditionStatement(block);
|
|
29633
29846
|
if (block.startsWith("git add -A || exit 1\ngtd_sign_cfg=")) return recognizeKeepTurn(repo, block);
|
|
29847
|
+
if (block.startsWith("git add -A -- ")) {
|
|
29848
|
+
const [path, message] = extractQuotedTokens(block);
|
|
29849
|
+
if (path === void 0 || message === void 0 || commitPath(path, message) !== block) return;
|
|
29850
|
+
const failure = repo.commitPath(path, message);
|
|
29851
|
+
return failure === void 0 ? { kind: "applied" } : {
|
|
29852
|
+
kind: "failed",
|
|
29853
|
+
error: failure,
|
|
29854
|
+
status: 1
|
|
29855
|
+
};
|
|
29856
|
+
}
|
|
29634
29857
|
if (block.startsWith("git add -A || exit 1\n")) {
|
|
29635
29858
|
const [message] = extractQuotedTokens(conditionLine);
|
|
29636
29859
|
if (message !== void 0 && commitAll(message) === block) {
|
|
@@ -29708,6 +29931,19 @@ const recognizeGitBuilders = (repo, block) => {
|
|
|
29708
29931
|
return;
|
|
29709
29932
|
}
|
|
29710
29933
|
};
|
|
29934
|
+
/** What the in-memory `gtd bundle` prints: a pin header, and no engine behind it. */
|
|
29935
|
+
const IN_MEMORY_PIN = "gtdpin 1 gtd in-memory\n";
|
|
29936
|
+
/** A pin or unpin beat's script. */
|
|
29937
|
+
const recognizePinScripts = (repo, block) => {
|
|
29938
|
+
if (block === pinScript(".gtd/process.gtdpin")) {
|
|
29939
|
+
repo.writeFile(PIN_PATH, IN_MEMORY_PIN);
|
|
29940
|
+
return { kind: "applied" };
|
|
29941
|
+
}
|
|
29942
|
+
if (block === unpinScript(".gtd/process.gtdpin")) {
|
|
29943
|
+
if (repo.readFile(".gtd/process.gtdpin") !== void 0) repo.deleteFile(PIN_PATH);
|
|
29944
|
+
return { kind: "applied" };
|
|
29945
|
+
}
|
|
29946
|
+
};
|
|
29711
29947
|
/** The REAL `fileExistsGuard` block, re-derived from the extracted path and string-compared. The one recognizer that can report `{ kind: "stopped" }`: a real `exit 0` there ends the script successfully. */
|
|
29712
29948
|
const recognizeFileExistsGuard = (repo, block) => {
|
|
29713
29949
|
const [file] = extractQuotedTokens(block);
|
|
@@ -30073,6 +30309,7 @@ const recognizersFor = (repo, commands, git) => [
|
|
|
30073
30309
|
(block) => recognizeBinaryGuard(commands, block),
|
|
30074
30310
|
(block) => git ? recognizeGitBuilders(repo, block) : void 0,
|
|
30075
30311
|
(block) => recognizeWriteLines(repo, block),
|
|
30312
|
+
(block) => recognizePinScripts(repo, block),
|
|
30076
30313
|
(block) => recognizeFailurePromptWrapper(repo, commands, block),
|
|
30077
30314
|
(block) => recognizePresentationSubshell(repo, commands, block, git),
|
|
30078
30315
|
(block) => recognizeModeContradictionSkipNotice(block),
|
|
@@ -30310,12 +30547,16 @@ const expectedVerdict = (doc) => {
|
|
|
30310
30547
|
return ["expected a JSON array, one entry per question — answer a boolean for a noul, a string for a choice, a number for a score; p between 0 and 1:", `[${questions.map(({ id, primitive }) => `{ "id": ${JSON.stringify(id)}, "answer": ${ANSWER[primitive] ?? "\"<answer>\""}, "p": 0.9 }`).join(", ")}]`].join("\n");
|
|
30311
30548
|
};
|
|
30312
30549
|
const quoted = (names) => names.map((name) => `"${name}"`).join(", ");
|
|
30313
|
-
/** Who acts at `doc`: its scripted actor, or
|
|
30550
|
+
/** Who acts at `doc`: its scripted actor, or its own script at a pin beat or a check under `checks: "run"`. A stall has none. */
|
|
30314
30551
|
const actorAt = (doc, actors, checks) => {
|
|
30315
30552
|
if (doc.kind === "stalled") return {
|
|
30316
30553
|
scripted: false,
|
|
30317
30554
|
actor: void 0
|
|
30318
30555
|
};
|
|
30556
|
+
if (doc.pin !== void 0) return {
|
|
30557
|
+
scripted: false,
|
|
30558
|
+
actor: runsItsScript
|
|
30559
|
+
};
|
|
30319
30560
|
if (Object.hasOwn(actors, doc.state)) return {
|
|
30320
30561
|
scripted: true,
|
|
30321
30562
|
actor: actors[doc.state]
|