@pmelab/gtd 27.7.0 → 27.8.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.
@@ -2,6 +2,9 @@
2
2
 
3
3
  import type { Stop } from "../types"
4
4
  import type { Beat } from "./drive"
5
+ import type { Shipped } from "./ship"
6
+ import { JOB_NAME } from "./tools"
7
+ import type { JobName } from "./tools"
5
8
 
6
9
  const step = (s: { label?: string; state?: string }) => s.label ?? s.state ?? "the next step"
7
10
 
@@ -53,3 +56,11 @@ export function pushText(stop: Stop, repo: string, url?: string) {
53
56
  if (stop.kind !== "gate") return at
54
57
  return url ? `${at}. Open: ${url}` : at
55
58
  }
59
+
60
+ const capitalised = (s: string) => s[0]!.toUpperCase() + s.slice(1)
61
+
62
+ export const jobHeadline = (job: JobName, outcome: Shipped) =>
63
+ `${outcome.ok ? "✔" : "✘"} ${capitalised(JOB_NAME[job])} ${outcome.ok ? "finished" : "failed"}`
64
+
65
+ export const jobPushText = (job: JobName, outcome: Shipped, repo: string) =>
66
+ `${repo}: ${JOB_NAME[job]} ${outcome.ok ? "finished" : "failed"}. ${outcome.text}`.trim()
@@ -1234,13 +1234,13 @@ const fixRisks = (notes) => agentWithSkills("review.fix.risks.fixing", buildRevi
1234
1234
  system: reviewerSystem(),
1235
1235
  allowEmpty: true
1236
1236
  });
1237
- const awaitReview = (base, message = buildReviewAwaitReviewMessage(base), acceptClean = false) => human("review.await-review", {
1237
+ const awaitReview = (base, message = buildReviewAwaitReviewMessage(base)) => human("review.await-review", {
1238
1238
  message,
1239
1239
  label: "Awaiting your review",
1240
1240
  file: REVIEW,
1241
1241
  mode: "review",
1242
1242
  base,
1243
- ...acceptClean ? { acceptClean } : {}
1243
+ acceptClean: true
1244
1244
  });
1245
1245
  /** The round at commit `round` left no `.gtd/REVIEW.md`. */
1246
1246
  const reviewMissing = (round) => human("review.review-missing", {
@@ -1,6 +1,6 @@
1
1
  import { read, refuse, removeScript, run, scope, showScript, vars } from "../flows.mjs";
2
2
  import { fixFindings, lensesOf, qualityLap as qualityLap$1, reviewToHandOff } from "../workflow-kit.mjs";
3
- import { C as fixRisks, D as reviewing, E as reviewQuality, G as handOffAwaitReviewMessage, S as fixQuality, T as reviewMissing, f as guarded, g as QUALITY, p as healthy, u as escalation, v as REVIEW, x as fixCheck, y as awaitReview } from "./reserved-Df8GeSHw.mjs";
3
+ import { C as fixRisks, D as reviewing, E as reviewQuality, G as handOffAwaitReviewMessage, S as fixQuality, T as reviewMissing, f as guarded, g as QUALITY, p as healthy, u as escalation, v as REVIEW, x as fixCheck, y as awaitReview } from "./reserved-UdSuHDUJ.mjs";
4
4
  //#region src/workflows/shared/tail.ts
5
5
  /** The reviewer's own `.gtd/REVIEW.md`, kept beside the human's notes so `fix` can tell them apart. */
6
6
  const REVIEW_ORIG = ".gtd/REVIEW.orig.md";
package/dist/flows.mjs CHANGED
@@ -86,6 +86,20 @@ const scope = async (scope, fn) => {
86
86
  context.popScope();
87
87
  }
88
88
  };
89
+ /**
90
+ * Mark the span `fn` covers as one named slice of the process. Lands no
91
+ * commit, prefixes no step name, opens no memory scope.
92
+ */
93
+ const slice = async (name, fn) => {
94
+ const context = ctx();
95
+ if (typeof context.enterSlice !== "function") return fn();
96
+ context.enterSlice(name);
97
+ try {
98
+ return await fn();
99
+ } finally {
100
+ context.leaveSlice?.();
101
+ }
102
+ };
89
103
  const read = (path) => ctx().read(path);
90
104
  /** Every path in the tree matching `pattern` (`*` stays within a segment, `**` crosses them). */
91
105
  const glob = (pattern) => ctx().glob(pattern);
@@ -652,10 +666,11 @@ var flows_exports = /* @__PURE__ */ __exportAll({
652
666
  sections: () => sections,
653
667
  showScript: () => showScript,
654
668
  skillsFor: () => skillsFor,
669
+ slice: () => slice,
655
670
  start: () => start,
656
671
  threads: () => threads,
657
672
  vars: () => vars,
658
673
  wrote: () => wrote
659
674
  });
660
675
  //#endregion
661
- export { FLOWS_PROTOCOL, FlowsProtocolError, accessFor, agent, answered, changes, changesSince, check, checkScript, codeChanges, codeThreads, configProblems, configure, defineConfig, defineWorkflow, displayPath, env, glob, hasThreadFor, head, human, installContext, isFactoryWorkflow, judge, judgeDiffShape, kept, mark, matches, moveScript, numeric, openQuestions, protocolMismatch, quote, read, refuse, removeScript, requireAnswers, requireProgress, requireReplies, requireRevert, requireThreadsClosed, restart, restoreScript, revertScript, run, scope, seamGate, sectionBodies, sections, showScript, skillsFor, start, flows_exports as t, threads, vars, wrote };
676
+ export { FLOWS_PROTOCOL, FlowsProtocolError, accessFor, agent, answered, changes, changesSince, check, checkScript, codeChanges, codeThreads, configProblems, configure, defineConfig, defineWorkflow, displayPath, env, glob, hasThreadFor, head, human, installContext, isFactoryWorkflow, judge, judgeDiffShape, kept, mark, matches, moveScript, numeric, openQuestions, protocolMismatch, quote, read, refuse, removeScript, requireAnswers, requireProgress, requireReplies, requireRevert, requireThreadsClosed, restart, restoreScript, revertScript, run, scope, seamGate, sectionBodies, sections, showScript, skillsFor, slice, start, flows_exports as t, threads, vars, wrote };
@@ -21724,6 +21724,20 @@ const scope = async (scope, fn) => {
21724
21724
  context.popScope();
21725
21725
  }
21726
21726
  };
21727
+ /**
21728
+ * Mark the span `fn` covers as one named slice of the process. Lands no
21729
+ * commit, prefixes no step name, opens no memory scope.
21730
+ */
21731
+ const slice = async (name, fn) => {
21732
+ const context = ctx();
21733
+ if (typeof context.enterSlice !== "function") return fn();
21734
+ context.enterSlice(name);
21735
+ try {
21736
+ return await fn();
21737
+ } finally {
21738
+ context.leaveSlice?.();
21739
+ }
21740
+ };
21727
21741
  const read = (path) => ctx().read(path);
21728
21742
  /** Every path in the tree matching `pattern` (`*` stays within a segment, `**` crosses them). */
21729
21743
  const glob = (pattern) => ctx().glob(pattern);
@@ -22290,6 +22304,7 @@ var flows_exports = /* @__PURE__ */ __exportAll({
22290
22304
  sections: () => sections,
22291
22305
  showScript: () => showScript,
22292
22306
  skillsFor: () => skillsFor,
22307
+ slice: () => slice,
22293
22308
  start: () => start,
22294
22309
  threads: () => threads,
22295
22310
  vars: () => vars,
@@ -37670,6 +37685,32 @@ const refNameFault = (name) => {
37670
37685
  }
37671
37686
  };
37672
37687
  //#endregion
37688
+ //#region src/replay/Slices.ts
37689
+ const IMPLICIT_SLICE = "main";
37690
+ /** Same shape as mark()'s name rule; kept local so @pmelab/gtd/flows publishes no regex. */
37691
+ const SLICE_NAME = /^[a-z][a-z0-9-]*$/;
37692
+ /**
37693
+ * Folds boundaries into ranges: each kept span's base is the previous kept
37694
+ * span's `leftAt`, so a gap before a span belongs to it, and the last span
37695
+ * runs to the episode tip. A closed span that landed nothing is dropped.
37696
+ */
37697
+ const sliceRanges = (spans, episode) => {
37698
+ if (spans.some((s) => s.enteredAt === "" || s.leftAt === "")) throw new Error("sliceRanges: a pending turn's \"\" hash is not a commit");
37699
+ const kept = spans.filter((s) => s.leftAt === void 0 || s.enteredAt !== s.leftAt);
37700
+ if (kept.length === 0) return [{
37701
+ name: IMPLICIT_SLICE,
37702
+ base: episode.base,
37703
+ tip: episode.tip,
37704
+ open: true
37705
+ }];
37706
+ return kept.map((s, i) => ({
37707
+ name: s.name,
37708
+ base: i === 0 ? episode.base : kept[i - 1].leftAt ?? episode.base,
37709
+ tip: i === kept.length - 1 ? episode.tip : s.leftAt ?? episode.tip,
37710
+ open: i === kept.length - 1
37711
+ }));
37712
+ };
37713
+ //#endregion
37673
37714
  //#region src/replay/Tree.ts
37674
37715
  const treeFromRecord = (files) => ({
37675
37716
  paths: () => Object.keys(files).sort(),
@@ -38017,6 +38058,7 @@ const replay = async (input) => {
38017
38058
  const keptTips = /* @__PURE__ */ new Map();
38018
38059
  let landed;
38019
38060
  const marks = [];
38061
+ const slices = [];
38020
38062
  let outcome;
38021
38063
  let signal;
38022
38064
  const settled = new Promise((resolve) => {
@@ -38223,7 +38265,8 @@ const replay = async (input) => {
38223
38265
  rest: step,
38224
38266
  trace,
38225
38267
  landed,
38226
- marks
38268
+ marks,
38269
+ slices: slices.map((s) => ({ ...s }))
38227
38270
  });
38228
38271
  return landPending(step, input.pending);
38229
38272
  };
@@ -38287,6 +38330,31 @@ const replay = async (input) => {
38287
38330
  scopes.pop();
38288
38331
  scopeCallIds.pop();
38289
38332
  },
38333
+ enterSlice: (name) => {
38334
+ if (outcome !== void 0) return;
38335
+ const fail = (message) => {
38336
+ finish({
38337
+ kind: "failed",
38338
+ message
38339
+ });
38340
+ throw new Stop();
38341
+ };
38342
+ if (typeof name !== "string" || !SLICE_NAME.test(name)) fail(`gtd: slice "${name}": name must match ${SLICE_NAME}`);
38343
+ if (name === "main") fail(`gtd: slice "${IMPLICIT_SLICE}" is reserved for the implicit slice`);
38344
+ const open = slices.find((s) => s.leftAt === void 0);
38345
+ if (open !== void 0) fail(`gtd: slice "${name}" is entered inside slice "${open.name}" — slices do not nest`);
38346
+ if (slices.some((s) => s.name === name)) fail(`gtd: slice "${name}" is entered twice in this episode — a loop must sit inside its slice`);
38347
+ slices.push({
38348
+ name,
38349
+ enteredAt: position.hash,
38350
+ leftAt: void 0
38351
+ });
38352
+ },
38353
+ leaveSlice: () => {
38354
+ if (outcome !== void 0) return;
38355
+ const open = slices.find((s) => s.leftAt === void 0);
38356
+ if (open !== void 0) open.leftAt = position.hash;
38357
+ },
38290
38358
  read: (path) => position.tree.read(path),
38291
38359
  glob: (pattern) => position.tree.paths().filter((path) => globMatches(path, pattern)),
38292
38360
  changes: () => changesBetween(previousPosition.tree, position.tree),
@@ -39976,6 +40044,10 @@ const pendingChanges = (git, withIndex) => gen(function* () {
39976
40044
  const seen = new Set(worktree.map((e) => e.path));
39977
40045
  return [...worktree, ...(yield* git.stagedPaths()).filter((e) => !seen.has(e.path))];
39978
40046
  });
40047
+ const sliceRangesOf = (slices, run) => sliceRanges(slices, {
40048
+ base: run.startParentHash,
40049
+ tip: run.trace.at(-1)?.hash ?? run.startParentHash
40050
+ });
39979
40051
  /** Resolve the rest at `ref`, or at HEAD when `ref` is `undefined`. */
39980
40052
  const restAt = (ref, startOverrides = {}) => gen(function* () {
39981
40053
  const git = yield* GitService;
@@ -40018,7 +40090,8 @@ const restAt = (ref, startOverrides = {}) => gen(function* () {
40018
40090
  total: run.costEntries.reduce((sum, entry) => sum + entry.cost, 0),
40019
40091
  byModel: costByModel(run.costEntries)
40020
40092
  },
40021
- setup
40093
+ setup,
40094
+ slices: sliceRangesOf(outcome.slices, run)
40022
40095
  };
40023
40096
  });
40024
40097
  const currentRest = restAt(void 0);
package/dist/testkit.mjs CHANGED
@@ -6817,6 +6817,32 @@ const refNameFault = (name) => {
6817
6817
  }
6818
6818
  };
6819
6819
  //#endregion
6820
+ //#region src/replay/Slices.ts
6821
+ const IMPLICIT_SLICE = "main";
6822
+ /** Same shape as mark()'s name rule; kept local so @pmelab/gtd/flows publishes no regex. */
6823
+ const SLICE_NAME = /^[a-z][a-z0-9-]*$/;
6824
+ /**
6825
+ * Folds boundaries into ranges: each kept span's base is the previous kept
6826
+ * span's `leftAt`, so a gap before a span belongs to it, and the last span
6827
+ * runs to the episode tip. A closed span that landed nothing is dropped.
6828
+ */
6829
+ const sliceRanges = (spans, episode) => {
6830
+ if (spans.some((s) => s.enteredAt === "" || s.leftAt === "")) throw new Error("sliceRanges: a pending turn's \"\" hash is not a commit");
6831
+ const kept = spans.filter((s) => s.leftAt === void 0 || s.enteredAt !== s.leftAt);
6832
+ if (kept.length === 0) return [{
6833
+ name: IMPLICIT_SLICE,
6834
+ base: episode.base,
6835
+ tip: episode.tip,
6836
+ open: true
6837
+ }];
6838
+ return kept.map((s, i) => ({
6839
+ name: s.name,
6840
+ base: i === 0 ? episode.base : kept[i - 1].leftAt ?? episode.base,
6841
+ tip: i === kept.length - 1 ? episode.tip : s.leftAt ?? episode.tip,
6842
+ open: i === kept.length - 1
6843
+ }));
6844
+ };
6845
+ //#endregion
6820
6846
  //#region src/replay/Tree.ts
6821
6847
  const treeFromRecord = (files) => ({
6822
6848
  paths: () => Object.keys(files).sort(),
@@ -7164,6 +7190,7 @@ const replay = async (input) => {
7164
7190
  const keptTips = /* @__PURE__ */ new Map();
7165
7191
  let landed;
7166
7192
  const marks = [];
7193
+ const slices = [];
7167
7194
  let outcome;
7168
7195
  let signal;
7169
7196
  const settled = new Promise((resolve) => {
@@ -7370,7 +7397,8 @@ const replay = async (input) => {
7370
7397
  rest: step,
7371
7398
  trace,
7372
7399
  landed,
7373
- marks
7400
+ marks,
7401
+ slices: slices.map((s) => ({ ...s }))
7374
7402
  });
7375
7403
  return landPending(step, input.pending);
7376
7404
  };
@@ -7434,6 +7462,31 @@ const replay = async (input) => {
7434
7462
  scopes.pop();
7435
7463
  scopeCallIds.pop();
7436
7464
  },
7465
+ enterSlice: (name) => {
7466
+ if (outcome !== void 0) return;
7467
+ const fail = (message) => {
7468
+ finish({
7469
+ kind: "failed",
7470
+ message
7471
+ });
7472
+ throw new Stop();
7473
+ };
7474
+ if (typeof name !== "string" || !SLICE_NAME.test(name)) fail(`gtd: slice "${name}": name must match ${SLICE_NAME}`);
7475
+ if (name === "main") fail(`gtd: slice "${IMPLICIT_SLICE}" is reserved for the implicit slice`);
7476
+ const open = slices.find((s) => s.leftAt === void 0);
7477
+ if (open !== void 0) fail(`gtd: slice "${name}" is entered inside slice "${open.name}" — slices do not nest`);
7478
+ if (slices.some((s) => s.name === name)) fail(`gtd: slice "${name}" is entered twice in this episode — a loop must sit inside its slice`);
7479
+ slices.push({
7480
+ name,
7481
+ enteredAt: position.hash,
7482
+ leftAt: void 0
7483
+ });
7484
+ },
7485
+ leaveSlice: () => {
7486
+ if (outcome !== void 0) return;
7487
+ const open = slices.find((s) => s.leftAt === void 0);
7488
+ if (open !== void 0) open.leftAt = position.hash;
7489
+ },
7437
7490
  read: (path) => position.tree.read(path),
7438
7491
  glob: (pattern) => position.tree.paths().filter((path) => globMatches(path, pattern)),
7439
7492
  changes: () => changesBetween(previousPosition.tree, position.tree),
@@ -9112,6 +9165,10 @@ const pendingChanges = (git, withIndex) => gen(function* () {
9112
9165
  const seen = new Set(worktree.map((e) => e.path));
9113
9166
  return [...worktree, ...(yield* git.stagedPaths()).filter((e) => !seen.has(e.path))];
9114
9167
  });
9168
+ const sliceRangesOf = (slices, run) => sliceRanges(slices, {
9169
+ base: run.startParentHash,
9170
+ tip: run.trace.at(-1)?.hash ?? run.startParentHash
9171
+ });
9115
9172
  /** Resolve the rest at `ref`, or at HEAD when `ref` is `undefined`. */
9116
9173
  const restAt = (ref, startOverrides = {}) => gen(function* () {
9117
9174
  const git = yield* GitService;
@@ -9154,7 +9211,8 @@ const restAt = (ref, startOverrides = {}) => gen(function* () {
9154
9211
  total: run.costEntries.reduce((sum, entry) => sum + entry.cost, 0),
9155
9212
  byModel: costByModel(run.costEntries)
9156
9213
  },
9157
- setup
9214
+ setup,
9215
+ slices: sliceRangesOf(outcome.slices, run)
9158
9216
  };
9159
9217
  });
9160
9218
  const currentRest = restAt(void 0);
@@ -1,6 +1,6 @@
1
1
  import { agent, changes, glob, read, refuse, scope, start, vars } from "../flows.mjs";
2
- import { a as flagShipped, b as coder, c as envDefaults, i as skills, n as summary, r as access, s as defaults, t as steering } from "../chunks/reserved-Df8GeSHw.mjs";
3
- import { i as refusePendingHandOff, r as handOffTail } from "../chunks/tail-DmIfEAI4.mjs";
2
+ import { a as flagShipped, b as coder, c as envDefaults, i as skills, n as summary, r as access, s as defaults, t as steering } from "../chunks/reserved-UdSuHDUJ.mjs";
3
+ import { i as refusePendingHandOff, r as handOffTail } from "../chunks/tail-fj60rQ3y.mjs";
4
4
  import { i as requireFlag, r as registryEntry, t as flagReferences } from "../chunks/registry-CP3YuV9j.mjs";
5
5
  //#region src/workflows/cleanup/cleanup.ts
6
6
  const removingPrompt = (name, registry) => `Clean up the release flag "${name}", which is default-on: delete its entry in ${registry}, and remove every \`GTD_FLAG_${name.toUpperCase()}\` check from the shipped sources. Keep the flag-on path; delete code only the off path reached, including tests that pin the off path.`;
@@ -1,7 +1,7 @@
1
1
  import { answered, changes, changesSince, glob, hasThreadFor, head, human, judge, judgeDiffShape, kept, mark, numeric, quote, read, refuse, removeScript, requireAnswers, requireProgress, requireReplies, requireRevert, requireThreadsClosed, restoreScript, revertScript, run, scope, seamGate, start, vars } from "../flows.mjs";
2
2
  import { review as review$1 } from "../workflow-kit.mjs";
3
- import { $ as refactorFixPrompt, A as architectureAuthorPrompt, B as designGateAnswerMessage, C as fixRisks, D as reviewing, F as buildReviewCollectingPrompt, H as designTriagePrompt, I as buildReviewFixNitsPrompt, J as implementFixPrompt, K as idleMessage, L as buildReviewTriageMessage, M as architectureGateAnswerMessage, N as buildReviewAnswerQuestionsPrompt, O as agentWithSkills, P as buildReviewAwaitAfterDeferMessage, Q as refactorBuildingPrompt, R as builderSystem, T as reviewMissing, V as designSystem, X as packagesItemBuildingPrompt, Y as naivePassPrompt, Z as packagesItemFixSuitePrompt, _ as REQUIREMENTS, at as seamByHandMessage, b as coder, c as envDefaults, ct as unwindFailedMessage, d as gate, et as reviewEditNotesCapture, h as FEEDBACK, i as skills, it as reviewerSystem, j as architectureDecomposePrompt, k as architectSystem, lt as unwindFailure, m as ARCHITECTURE, n as summary, o as shipped, ot as seamSteerMessage, p as healthy, q as implementBuildingPrompt, r as access, rt as reviewNotesCapture, s as defaults, st as startGateBlockedMessage, t as steering, tt as reviewEditsCapture, v as REVIEW, w as planner, x as fixCheck, y as awaitReview, z as deferNotesScript } from "../chunks/reserved-Df8GeSHw.mjs";
4
- import { a as tail, i as refusePendingHandOff, n as closeReview } from "../chunks/tail-DmIfEAI4.mjs";
3
+ import { $ as refactorFixPrompt, A as architectureAuthorPrompt, B as designGateAnswerMessage, C as fixRisks, D as reviewing, F as buildReviewCollectingPrompt, H as designTriagePrompt, I as buildReviewFixNitsPrompt, J as implementFixPrompt, K as idleMessage, L as buildReviewTriageMessage, M as architectureGateAnswerMessage, N as buildReviewAnswerQuestionsPrompt, O as agentWithSkills, P as buildReviewAwaitAfterDeferMessage, Q as refactorBuildingPrompt, R as builderSystem, T as reviewMissing, V as designSystem, X as packagesItemBuildingPrompt, Y as naivePassPrompt, Z as packagesItemFixSuitePrompt, _ as REQUIREMENTS, at as seamByHandMessage, b as coder, c as envDefaults, ct as unwindFailedMessage, d as gate, et as reviewEditNotesCapture, h as FEEDBACK, i as skills, it as reviewerSystem, j as architectureDecomposePrompt, k as architectSystem, lt as unwindFailure, m as ARCHITECTURE, n as summary, o as shipped, ot as seamSteerMessage, p as healthy, q as implementBuildingPrompt, r as access, rt as reviewNotesCapture, s as defaults, st as startGateBlockedMessage, t as steering, tt as reviewEditsCapture, v as REVIEW, w as planner, x as fixCheck, y as awaitReview, z as deferNotesScript } from "../chunks/reserved-UdSuHDUJ.mjs";
4
+ import { a as tail, i as refusePendingHandOff, n as closeReview } from "../chunks/tail-fj60rQ3y.mjs";
5
5
  //#region src/workflows/feature/tier.ts
6
6
  /** GTD_FLAG_NAIVE_PASS, GTD_FLAG_RISK_TIER: both pinned at process start, so a mid-process flip never moves a process. */
7
7
  const riskTierOn = () => vars.flag_naive_pass === "1" && vars.flag_risk_tier === "1";
@@ -355,7 +355,7 @@ const review = (base, escalations = { rounds: 0 }) => {
355
355
  awaitReview: (b) => {
356
356
  const notes = deferred;
357
357
  deferred = [];
358
- return notes.length > 0 ? awaitReview(b, buildReviewAwaitAfterDeferMessage(b, notes), true) : awaitReview(b);
358
+ return notes.length > 0 ? awaitReview(b, buildReviewAwaitAfterDeferMessage(b, notes)) : awaitReview(b);
359
359
  },
360
360
  reviewMissing,
361
361
  collecting,
@@ -1,7 +1,7 @@
1
1
  import { read, refuse, removeScript, run, scope, start } from "../flows.mjs";
2
2
  import { d as reviewNotes, f as reviewRisks } from "../chunks/steering-DVILanVf.mjs";
3
- import { O as agentWithSkills, U as finisherSystem, W as fixReviewNotesPrompt, b as coder, c as envDefaults, i as skills, l as baseline, n as summary, o as shipped, r as access, s as defaults, t as steering, v as REVIEW } from "../chunks/reserved-Df8GeSHw.mjs";
4
- import { r as handOffTail, t as REVIEW_ORIG } from "../chunks/tail-DmIfEAI4.mjs";
3
+ import { O as agentWithSkills, U as finisherSystem, W as fixReviewNotesPrompt, b as coder, c as envDefaults, i as skills, l as baseline, n as summary, o as shipped, r as access, s as defaults, t as steering, v as REVIEW } from "../chunks/reserved-UdSuHDUJ.mjs";
4
+ import { r as handOffTail, t as REVIEW_ORIG } from "../chunks/tail-fj60rQ3y.mjs";
5
5
  //#region src/workflows/fix/fix.ts
6
6
  /**
7
7
  * Fix every note a human left in a handed-off `.gtd/REVIEW.md` and every
@@ -1,6 +1,6 @@
1
1
  import { start } from "../flows.mjs";
2
- import { c as envDefaults, d as gate, i as skills, n as summary, nt as reviewGateBlockedMessage, o as shipped, r as access, s as defaults, t as steering } from "../chunks/reserved-Df8GeSHw.mjs";
3
- import { i as refusePendingHandOff, r as handOffTail } from "../chunks/tail-DmIfEAI4.mjs";
2
+ import { c as envDefaults, d as gate, i as skills, n as summary, nt as reviewGateBlockedMessage, o as shipped, r as access, s as defaults, t as steering } from "../chunks/reserved-UdSuHDUJ.mjs";
3
+ import { i as refusePendingHandOff, r as handOffTail } from "../chunks/tail-fj60rQ3y.mjs";
4
4
  //#region src/workflows/review/review.ts
5
5
  /**
6
6
  * Pure review of everything since `reviewBase`: a green gate, the quality
@@ -1,5 +1,5 @@
1
1
  import { agent, changesSince, head, read, refuse, scope, vars } from "../flows.mjs";
2
- import { a as flagShipped, b as coder, c as envDefaults, d as gate, i as skills, n as summary, r as access, s as defaults, t as steering } from "../chunks/reserved-Df8GeSHw.mjs";
2
+ import { a as flagShipped, b as coder, c as envDefaults, d as gate, i as skills, n as summary, r as access, s as defaults, t as steering } from "../chunks/reserved-UdSuHDUJ.mjs";
3
3
  import { i as requireFlag, n as flipProblems } from "../chunks/registry-CP3YuV9j.mjs";
4
4
  //#region src/workflows/rollout/rollout.ts
5
5
  const flippingPrompt = (name, registry) => `Roll out the release flag "${name}": in ${registry}, set \`defaultOn: true\` on its entry. Change nothing else — no other entry, no other field, no other file, no comments.`;
@@ -17995,13 +17995,13 @@ const fixRisks = (notes) => agentWithSkills("review.fix.risks.fixing", buildRevi
17995
17995
  system: reviewerSystem(),
17996
17996
  allowEmpty: true
17997
17997
  });
17998
- const awaitReview = (base, message = buildReviewAwaitReviewMessage(base), acceptClean = false) => human("review.await-review", {
17998
+ const awaitReview = (base, message = buildReviewAwaitReviewMessage(base)) => human("review.await-review", {
17999
17999
  message,
18000
18000
  label: "Awaiting your review",
18001
18001
  file: REVIEW,
18002
18002
  mode: "review",
18003
18003
  base,
18004
- ...acceptClean ? { acceptClean } : {}
18004
+ acceptClean: true
18005
18005
  });
18006
18006
  /** The round at commit `round` left no `.gtd/REVIEW.md`. */
18007
18007
  const reviewMissing = (round) => human("review.review-missing", {
@@ -37911,7 +37911,7 @@ const review$1 = (base, escalations = { rounds: 0 }) => {
37911
37911
  awaitReview: (b) => {
37912
37912
  const notes = deferred;
37913
37913
  deferred = [];
37914
- return notes.length > 0 ? awaitReview(b, buildReviewAwaitAfterDeferMessage(b, notes), true) : awaitReview(b);
37914
+ return notes.length > 0 ? awaitReview(b, buildReviewAwaitAfterDeferMessage(b, notes)) : awaitReview(b);
37915
37915
  },
37916
37916
  reviewMissing,
37917
37917
  collecting,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmelab/gtd",
3
- "version": "27.7.0",
3
+ "version": "27.8.0",
4
4
  "private": false,
5
5
  "description": "Git-aware CLI that emits the next prompt for an autonomous coding agent based on the current repository state",
6
6
  "bin": {
@@ -596,6 +596,34 @@ export const summary = (it) => it.marks.map((m) => m.name + "=" + m.value).join(
596
596
  })
597
597
  })
598
598
 
599
+ describe("Rest.slices", () => {
600
+ const SLICED = `import { agent, human, slice } from "@pmelab/gtd/flows"
601
+
602
+ export default async () => {
603
+ await human("idle", { message: "idle-message" })
604
+ await slice("build", async () => {
605
+ await agent("building", "build-prompt")
606
+ })
607
+ await slice("verify", async () => {
608
+ await agent("checking", "check-prompt")
609
+ })
610
+ }
611
+ `
612
+
613
+ it("folds the replay's spans over the run's base and its last commit", async () => {
614
+ const repo = repoWith(SLICED)
615
+ const boundary = headOf(repo)
616
+ await land(repo, { "a.txt": "a\n" })
617
+ const checking = await land(repo, { "b.txt": "b\n" })
618
+ const rest = await provide(currentRest, repo)
619
+ expect(rest.state).toBe("checking")
620
+ expect(rest.slices).toEqual([
621
+ { name: "build", base: boundary, tip: checking, open: false },
622
+ { name: "verify", base: checking, tip: checking, open: true },
623
+ ])
624
+ })
625
+ })
626
+
599
627
  describe("restAt", () => {
600
628
  it("restAt(ref) resolves where the process rested at that commit", async () => {
601
629
  const repo = repoWith(LINEAR)
package/src/edge/Edge.ts CHANGED
@@ -37,11 +37,13 @@ import {
37
37
  isRecordableApproval,
38
38
  parseCommitMessage,
39
39
  replay,
40
+ sliceRanges,
40
41
  treeFromRecord,
41
42
  type EpisodeCommit,
42
43
  type JudgeVerdict,
43
44
  type ReachedStep,
44
45
  type ReplayOutcome,
46
+ type SliceRange,
45
47
  type TreeView,
46
48
  } from "../replay/index.js"
47
49
  import type { Mark, ScopeAccess } from "../flows/index.js"
@@ -895,6 +897,7 @@ interface Rest extends ResolvedRest {
895
897
  /** Total recorded cost of the process, and its per-model breakdown. */
896
898
  readonly cost: { readonly total: number; readonly byModel: readonly ModelCost[] }
897
899
  readonly setup: ReplaySetup
900
+ readonly slices: readonly SliceRange[]
898
901
  }
899
902
 
900
903
  /** Per-model token totals, highest-cost first (ties broken by model name). */
@@ -987,6 +990,15 @@ const pendingChanges = (git: GitOperations, withIndex: boolean) =>
987
990
  return [...worktree, ...(yield* git.stagedPaths()).filter((e) => !seen.has(e.path))]
988
991
  })
989
992
 
993
+ const sliceRangesOf = (
994
+ slices: Parameters<typeof sliceRanges>[0],
995
+ run: { readonly startParentHash: string; readonly trace: readonly { readonly hash: string }[] },
996
+ ) =>
997
+ sliceRanges(slices, {
998
+ base: run.startParentHash,
999
+ tip: run.trace.at(-1)?.hash ?? run.startParentHash,
1000
+ })
1001
+
990
1002
  /** Resolve the rest at `ref`, or at HEAD when `ref` is `undefined`. */
991
1003
  export const restAt = (
992
1004
  ref: string | undefined,
@@ -1060,6 +1072,7 @@ export const restAt = (
1060
1072
  byModel: costByModel(run.costEntries),
1061
1073
  },
1062
1074
  setup,
1075
+ slices: sliceRangesOf(outcome.slices, run),
1063
1076
  }
1064
1077
  })
1065
1078
 
@@ -7,6 +7,7 @@ import {
7
7
  kept,
8
8
  mark,
9
9
  protocolMismatch,
10
+ slice,
10
11
  type FlowContext,
11
12
  } from "./runtime.js"
12
13
 
@@ -127,3 +128,29 @@ describe("kept()", () => {
127
128
  expect(FLOWS_PROTOCOL).toBe(1)
128
129
  })
129
130
  })
131
+
132
+ describe("slice()", () => {
133
+ const withSlices = () => {
134
+ const calls: string[] = []
135
+ installContext({
136
+ enterSlice: (n: string) => calls.push(`enter ${n}`),
137
+ leaveSlice: () => calls.push("leave"),
138
+ } as unknown as FlowContext)
139
+ return calls
140
+ }
141
+
142
+ it("brackets fn with enter and leave and resolves to its value", async () => {
143
+ const calls = withSlices()
144
+ const value = await slice("refactor", async () => {
145
+ calls.push("fn")
146
+ return 42
147
+ })
148
+ expect(value).toBe(42)
149
+ expect(calls).toEqual(["enter refactor", "fn", "leave"])
150
+ })
151
+
152
+ it("runs fn unsliced on a context without enterSlice", async () => {
153
+ installContext(context)
154
+ expect(await slice("refactor", async () => "ran")).toBe("ran")
155
+ })
156
+ })
@@ -150,6 +150,9 @@ export interface FlowContext {
150
150
  readonly refuse: (message: string) => never
151
151
  readonly pushScope: (scope: ScopeOptions) => void
152
152
  readonly popScope: () => void
153
+ /** Absent on an engine older than `slice`; FLOWS_PROTOCOL stays 1. */
154
+ readonly enterSlice?: (name: string) => void
155
+ readonly leaveSlice?: () => void
153
156
  readonly read: (path: string) => string | undefined
154
157
  readonly glob: (pattern: string) => readonly string[]
155
158
  /** The last step's changes, content read on demand. */
@@ -284,6 +287,21 @@ export const scope = async <T>(scope: string | ScopeOptions, fn: () => Promise<T
284
287
  }
285
288
  }
286
289
 
290
+ /**
291
+ * Mark the span `fn` covers as one named slice of the process. Lands no
292
+ * commit, prefixes no step name, opens no memory scope.
293
+ */
294
+ export const slice = async <T>(name: string, fn: () => Promise<T>): Promise<T> => {
295
+ const context = ctx()
296
+ if (typeof context.enterSlice !== "function") return fn()
297
+ context.enterSlice(name)
298
+ try {
299
+ return await fn()
300
+ } finally {
301
+ context.leaveSlice?.()
302
+ }
303
+ }
304
+
287
305
  // ── Reads of the commit replay stands on ────────────────────────────────────
288
306
 
289
307
  export const read = (path: string): string | undefined => ctx().read(path)