infinity-harness 2.0.4 → 2.2.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/CHANGELOG.md +111 -0
- package/README.md +98 -7
- package/extensions/infinity-harness/index.ts +771 -10
- package/harness/docs/ARCHITECTURE.md +1 -1
- package/harness/docs/phases/define.md +27 -9
- package/harness/docs/phases/ship.md +1 -1
- package/harness/skills/code-review.md +2 -2
- package/harness/skills/context-hygiene.md +1 -1
- package/harness/skills/diagnosing-bugs.md +1 -1
- package/harness/skills/domain-modeling.md +2 -1
- package/harness/skills/prototype.md +2 -2
- package/package.json +1 -1
- package/src/core/brief.ts +14 -0
- package/src/core/gates.ts +45 -4
- package/src/core/init.ts +379 -0
- package/src/escalate.ts +370 -0
- package/src/goal.ts +411 -0
- package/src/loop.ts +158 -12
- package/src/taskList.ts +154 -7
- package/src/ui/widget.ts +15 -0
- package/src/unstuck.ts +46 -19
|
@@ -25,14 +25,37 @@ import { buildBrief, renderBrief } from "../../src/core/brief.ts";
|
|
|
25
25
|
import { runChecks } from "../../src/core/gates.ts";
|
|
26
26
|
import { advancePhase } from "../../src/core/phases.ts";
|
|
27
27
|
import { configPath } from "../../src/core/paths.ts";
|
|
28
|
+
import { readJsonSafe } from "../../src/core/fsx.ts";
|
|
28
29
|
import { withLock } from "../../src/core/lock.ts";
|
|
29
|
-
import {
|
|
30
|
+
import {
|
|
31
|
+
DEFAULT_ENABLED_PHASES,
|
|
32
|
+
ValidationError,
|
|
33
|
+
type FeatureList,
|
|
34
|
+
type Phase,
|
|
35
|
+
} from "../../src/core/types.ts";
|
|
30
36
|
import { writeTaskList, summarizeApply, type TaskInput } from "../../src/taskList.ts";
|
|
31
37
|
import { renderWidget, renderStatusLine, type WidgetState } from "../../src/ui/widget.ts";
|
|
32
38
|
import { createStyler, detectGlyphs } from "../../src/ui/theme.ts";
|
|
33
|
-
import { decideNext, stopFilePath } from "../../src/loop.ts";
|
|
39
|
+
import { decideNext, stopFilePath, loopStatePath } from "../../src/loop.ts";
|
|
34
40
|
import { runConfigMenu, renderSettings, type ModelChoice, type Prompter } from "../../src/ui/config.ts";
|
|
35
41
|
import { SETTINGS, readAll, readSetting, formatValue } from "../../src/core/settings.ts";
|
|
42
|
+
import { detectStack, describeInit, initHarness, type StackId } from "../../src/core/init.ts";
|
|
43
|
+
import { startRework, loadRework, clearRework } from "../../src/rework.ts";
|
|
44
|
+
import { amendPlan, loadReplanHistory, type ReplanTaskInput } from "../../src/replan.ts";
|
|
45
|
+
import { chooseUnstuckStrategy } from "../../src/unstuck.ts";
|
|
46
|
+
import { escalationSummary } from "../../src/escalate.ts";
|
|
47
|
+
import { spawnIsolatedWorker } from "../../src/worker.ts";
|
|
48
|
+
import {
|
|
49
|
+
startGoal,
|
|
50
|
+
loadGoal,
|
|
51
|
+
reviewGoal,
|
|
52
|
+
cancelGoal,
|
|
53
|
+
recordPipelinePass,
|
|
54
|
+
viewOf,
|
|
55
|
+
describeGoal,
|
|
56
|
+
type ReviewInput,
|
|
57
|
+
} from "../../src/goal.ts";
|
|
58
|
+
import { flattenTasks } from "../../src/core/featureList.ts";
|
|
36
59
|
|
|
37
60
|
const CHECKPOINT = "infinity:checkpoint";
|
|
38
61
|
const WIDGET_KEY = "infinity-harness";
|
|
@@ -73,6 +96,14 @@ export default function (pi: ExtensionAPI): void {
|
|
|
73
96
|
try {
|
|
74
97
|
const { list } = loadFeatureList(dir);
|
|
75
98
|
const { config } = loadConfig(dir);
|
|
99
|
+
const spent = escalationSummary(dir);
|
|
100
|
+
const loop = readJsonSafe<{ escalations?: { strategy: string }[] } | null>(
|
|
101
|
+
loopStatePath(dir),
|
|
102
|
+
null,
|
|
103
|
+
);
|
|
104
|
+
const lastRung = loop?.escalations?.[loop.escalations.length - 1]?.strategy ?? null;
|
|
105
|
+
const pass = typeof config.goalPass === "number" ? config.goalPass : null;
|
|
106
|
+
const maxPasses = typeof config.goalMaxPasses === "number" ? config.goalMaxPasses : null;
|
|
76
107
|
return {
|
|
77
108
|
list,
|
|
78
109
|
phase: config.currentPhase,
|
|
@@ -80,6 +111,11 @@ export default function (pi: ExtensionAPI): void {
|
|
|
80
111
|
paused: Boolean(config.paused),
|
|
81
112
|
revision: list.baseRevision,
|
|
82
113
|
retries: { task: config.taskRetryCount ?? 0, max: config.maxRetries ?? 10 },
|
|
114
|
+
goalPass: pass && maxPasses ? { current: pass, max: maxPasses } : null,
|
|
115
|
+
escalation:
|
|
116
|
+
lastRung || spent.reworks || spent.replans
|
|
117
|
+
? { strategy: lastRung, reworks: spent.reworks, replans: spent.replans }
|
|
118
|
+
: null,
|
|
83
119
|
};
|
|
84
120
|
} catch {
|
|
85
121
|
return null;
|
|
@@ -438,22 +474,72 @@ export default function (pi: ExtensionAPI): void {
|
|
|
438
474
|
},
|
|
439
475
|
},
|
|
440
476
|
},
|
|
477
|
+
features: {
|
|
478
|
+
type: "array",
|
|
479
|
+
maxItems: 100,
|
|
480
|
+
description:
|
|
481
|
+
"Feature names and acceptance criteria, merged by id. Unlike tasks, omitting a feature " +
|
|
482
|
+
"here leaves it alone rather than deleting it. The DEFINE gate requires criteria on " +
|
|
483
|
+
"every feature, so this is how DEFINE is passed.",
|
|
484
|
+
items: {
|
|
485
|
+
type: "object",
|
|
486
|
+
required: ["id"],
|
|
487
|
+
properties: {
|
|
488
|
+
id: { type: "string", description: 'Feature id, e.g. "feature-001"' },
|
|
489
|
+
name: { type: "string", description: "What the feature is, in a few words" },
|
|
490
|
+
description: { type: "string" },
|
|
491
|
+
criteria: {
|
|
492
|
+
type: "array",
|
|
493
|
+
items: { type: "string" },
|
|
494
|
+
description: "How you will know this feature is done. Observable, not aspirational.",
|
|
495
|
+
},
|
|
496
|
+
},
|
|
497
|
+
},
|
|
498
|
+
},
|
|
499
|
+
goal: {
|
|
500
|
+
type: "string",
|
|
501
|
+
description: "One line: what this whole run is for. Shown at the top of every brief.",
|
|
502
|
+
},
|
|
441
503
|
},
|
|
442
504
|
} as never,
|
|
443
|
-
async execute(
|
|
505
|
+
async execute(
|
|
506
|
+
_id: string,
|
|
507
|
+
params: {
|
|
508
|
+
baseRevision?: number;
|
|
509
|
+
tasks?: TaskInput[];
|
|
510
|
+
features?: { id: string; name?: string; description?: string; criteria?: string[] }[];
|
|
511
|
+
goal?: string;
|
|
512
|
+
},
|
|
513
|
+
_signal,
|
|
514
|
+
_onUpdate,
|
|
515
|
+
ctx,
|
|
516
|
+
) {
|
|
444
517
|
const dir = projectDir(ctx);
|
|
445
518
|
|
|
446
|
-
|
|
519
|
+
// A submission with no tasks, no features and no goal is a read.
|
|
520
|
+
const writing =
|
|
521
|
+
Array.isArray(params?.tasks) || Array.isArray(params?.features) || typeof params?.goal === "string";
|
|
522
|
+
if (!writing) {
|
|
447
523
|
const { list } = loadFeatureList(dir);
|
|
448
524
|
const p = computeProgress(list);
|
|
525
|
+
// Features and their criteria are printed, not just tasks: the DEFINE
|
|
526
|
+
// gate judges criteria, so a plan view that hides them shows the model
|
|
527
|
+
// everything except the thing it is being marked on.
|
|
449
528
|
const rows = (list.features ?? [])
|
|
450
|
-
.flatMap((f) =>
|
|
529
|
+
.flatMap((f) => [
|
|
530
|
+
`${f.id} · ${f.name}${f.criteria?.length ? "" : " ← no acceptance criteria"}`,
|
|
531
|
+
...(f.criteria ?? []).map((c) => ` ✓ ${c}`),
|
|
532
|
+
...(f.tasks ?? []).map((t) => ` [${t.status}] ${t.key ?? t.id}: ${t.description}`),
|
|
533
|
+
])
|
|
451
534
|
.join("\n");
|
|
535
|
+
const goal = (list.goals ?? [])[0]?.title;
|
|
452
536
|
return {
|
|
453
537
|
content: [
|
|
454
538
|
{
|
|
455
539
|
type: "text",
|
|
456
|
-
text:
|
|
540
|
+
text:
|
|
541
|
+
`Plan revision ${list.baseRevision} — ${p.tasksDone}/${p.tasksTotal} tasks` +
|
|
542
|
+
`${goal ? `\nGoal: ${goal}` : ""}\n${rows || "(empty)"}`,
|
|
457
543
|
},
|
|
458
544
|
],
|
|
459
545
|
details: { revision: list.baseRevision, progress: p },
|
|
@@ -464,7 +550,12 @@ export default function (pi: ExtensionAPI): void {
|
|
|
464
550
|
// writeTaskList takes the plan lock itself, around the whole
|
|
465
551
|
// read-apply-write. Wrapping it again here would only add a second
|
|
466
552
|
// lock with weaker semantics.
|
|
467
|
-
const result = writeTaskList(dir, {
|
|
553
|
+
const result = writeTaskList(dir, {
|
|
554
|
+
baseRevision: params.baseRevision,
|
|
555
|
+
tasks: params.tasks,
|
|
556
|
+
features: params.features,
|
|
557
|
+
goal: params.goal,
|
|
558
|
+
});
|
|
468
559
|
refreshWidget(ctx as ExtensionContext);
|
|
469
560
|
return {
|
|
470
561
|
content: [{ type: "text", text: summarizeApply(result) }],
|
|
@@ -678,6 +769,542 @@ export default function (pi: ExtensionAPI): void {
|
|
|
678
769
|
},
|
|
679
770
|
});
|
|
680
771
|
|
|
772
|
+
// -- init -----------------------------------------------------------------
|
|
773
|
+
|
|
774
|
+
/**
|
|
775
|
+
* Said wherever a command finds no harness.
|
|
776
|
+
*
|
|
777
|
+
* It used to be "No harness in this project." and nothing else — a dead end
|
|
778
|
+
* with no exit, in a tool whose every other command needs a harness to work.
|
|
779
|
+
* A warning that does not say what to do instead is only half a warning.
|
|
780
|
+
*/
|
|
781
|
+
const NO_HARNESS = "No harness in this project yet. Run /infinity:init to create one.";
|
|
782
|
+
|
|
783
|
+
/** The escalation ladder, in the order it climbs. */
|
|
784
|
+
const DEFAULT_LADDER = ["retry", "reframe", "consult", "rework", "replan", "master"];
|
|
785
|
+
|
|
786
|
+
/** Everything the pipeline can run. INIT is not a phase you choose. */
|
|
787
|
+
const SELECTABLE_PHASES: Phase[] = ["define", "plan", "build", "verify", "simplify", "review", "ship"];
|
|
788
|
+
|
|
789
|
+
pi.registerTool({
|
|
790
|
+
name: "infinity_init",
|
|
791
|
+
label: "Init",
|
|
792
|
+
description:
|
|
793
|
+
"Create a harness in this project: config, an empty plan, the phase and role docs, and starters " +
|
|
794
|
+
"for the documents the review gate demands. Detects the stack and its lint/test/build commands. " +
|
|
795
|
+
"Refuses if a harness already exists unless force is set, and never overwrites an existing file.",
|
|
796
|
+
parameters: {
|
|
797
|
+
type: "object",
|
|
798
|
+
properties: {
|
|
799
|
+
mode: { type: "string", enum: ["copilot", "autopilot"], description: "copilot keeps the human in the loop" },
|
|
800
|
+
stack: { type: "string", enum: ["node", "python", "rust", "go", "unknown"] },
|
|
801
|
+
phases: {
|
|
802
|
+
type: "array",
|
|
803
|
+
items: { type: "string", enum: ["define", "plan", "build", "verify", "simplify", "review", "ship"] },
|
|
804
|
+
description: "Which phases run. Omit for the default pipeline.",
|
|
805
|
+
},
|
|
806
|
+
force: { type: "boolean", description: "Restore missing files in a project that already has a harness" },
|
|
807
|
+
},
|
|
808
|
+
} as never,
|
|
809
|
+
async execute(
|
|
810
|
+
_id: string,
|
|
811
|
+
params: { mode?: "copilot" | "autopilot"; stack?: StackId; phases?: Phase[]; force?: boolean },
|
|
812
|
+
_signal,
|
|
813
|
+
_onUpdate,
|
|
814
|
+
ctx,
|
|
815
|
+
) {
|
|
816
|
+
const dir = projectDir(ctx);
|
|
817
|
+
const result = initHarness(dir, {
|
|
818
|
+
mode: params?.mode,
|
|
819
|
+
stack: params?.stack,
|
|
820
|
+
phases: params?.phases,
|
|
821
|
+
force: params?.force,
|
|
822
|
+
});
|
|
823
|
+
if (!result.ok) {
|
|
824
|
+
return {
|
|
825
|
+
content: [{ type: "text", text: result.error ?? "init failed" }],
|
|
826
|
+
details: result,
|
|
827
|
+
isError: true,
|
|
828
|
+
};
|
|
829
|
+
}
|
|
830
|
+
refreshWidget(ctx as ExtensionContext);
|
|
831
|
+
return { content: [{ type: "text", text: describeInit(result) }], details: result };
|
|
832
|
+
},
|
|
833
|
+
});
|
|
834
|
+
|
|
835
|
+
pi.registerCommand("infinity:init", {
|
|
836
|
+
description: "Create a harness in this project",
|
|
837
|
+
handler: async (args: string, ctx: ExtensionContext) => {
|
|
838
|
+
const dir = projectDir(ctx);
|
|
839
|
+
const force = /\bforce\b/.test(args);
|
|
840
|
+
|
|
841
|
+
if (isHarnessProject(dir) && !force) {
|
|
842
|
+
notify(
|
|
843
|
+
ctx,
|
|
844
|
+
"This project already has a harness. /infinity:config changes it; /infinity:init force restores missing files.",
|
|
845
|
+
"warning",
|
|
846
|
+
);
|
|
847
|
+
return;
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
const detected = detectStack(dir);
|
|
851
|
+
let mode: "copilot" | "autopilot" = "copilot";
|
|
852
|
+
let phases: Phase[] | undefined;
|
|
853
|
+
|
|
854
|
+
// With dialogs, ask the two questions whose answers we cannot infer.
|
|
855
|
+
// Without them, take the detected defaults and say so — an unattended
|
|
856
|
+
// run must not stall on a prompt nobody will answer.
|
|
857
|
+
if (ctx.hasUI) {
|
|
858
|
+
const cmds = Object.entries(detected.commands).filter(([, v]) => Boolean(v));
|
|
859
|
+
const summary = cmds.length ? cmds.map(([k, v]) => `${k}: ${v}`).join(", ") : "no commands detected";
|
|
860
|
+
const go = await ctx.ui.select(
|
|
861
|
+
`Create a harness here? ${detected.label} · ${summary}`,
|
|
862
|
+
["yes, use these defaults", "yes, but let me choose the phases", "cancel"],
|
|
863
|
+
);
|
|
864
|
+
if (go === undefined || go === "cancel") {
|
|
865
|
+
notify(ctx, "init cancelled — nothing was written.", "info");
|
|
866
|
+
return;
|
|
867
|
+
}
|
|
868
|
+
const picked = await ctx.ui.select("How should it run?", [
|
|
869
|
+
"copilot — you stay in the loop",
|
|
870
|
+
"autopilot — it drives itself",
|
|
871
|
+
]);
|
|
872
|
+
if (picked?.startsWith("autopilot")) mode = "autopilot";
|
|
873
|
+
|
|
874
|
+
if (go.includes("phases")) {
|
|
875
|
+
const chosen = new Set<Phase>(DEFAULT_ENABLED_PHASES);
|
|
876
|
+
for (;;) {
|
|
877
|
+
const rows = SELECTABLE_PHASES.map((p) => `${chosen.has(p) ? "[x]" : "[ ]"} ${p}`);
|
|
878
|
+
const hit = await ctx.ui.select("Phases to run", [...rows, "✓ done"]);
|
|
879
|
+
if (hit === undefined || hit === "✓ done") break;
|
|
880
|
+
const key = SELECTABLE_PHASES[rows.indexOf(hit)];
|
|
881
|
+
if (!key) break;
|
|
882
|
+
if (chosen.has(key)) chosen.delete(key);
|
|
883
|
+
else chosen.add(key);
|
|
884
|
+
}
|
|
885
|
+
phases = [...chosen];
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
const result = initHarness(dir, { mode, phases, force });
|
|
890
|
+
if (!result.ok) {
|
|
891
|
+
notify(ctx, result.error ?? "init failed", "error");
|
|
892
|
+
return;
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
notify(ctx, describeInit(result), "info");
|
|
896
|
+
refreshWidget(ctx);
|
|
897
|
+
// Hand the model the brief straight away, so the session that created
|
|
898
|
+
// the harness is also the session that starts using it.
|
|
899
|
+
pi.sendUserMessage(await briefText(dir), { deliverAs: "followUp" });
|
|
900
|
+
},
|
|
901
|
+
});
|
|
902
|
+
|
|
903
|
+
|
|
904
|
+
// -- escalation, rework, replan --------------------------------------------
|
|
905
|
+
|
|
906
|
+
pi.registerTool({
|
|
907
|
+
name: "infinity_rework",
|
|
908
|
+
label: "Rework",
|
|
909
|
+
description:
|
|
910
|
+
"Send a task and everything that depends on it back to `rework`. Use when work built on a task " +
|
|
911
|
+
"turns out not to hold up: the dependents were built on the broken thing, so they are suspect " +
|
|
912
|
+
"until re-proved. Bounded by the rework budget.",
|
|
913
|
+
parameters: {
|
|
914
|
+
type: "object",
|
|
915
|
+
required: ["task"],
|
|
916
|
+
properties: {
|
|
917
|
+
task: { type: "string", description: 'Task key, e.g. "feature-001/task-003"' },
|
|
918
|
+
reason: { type: "string", description: "Why this is going backwards" },
|
|
919
|
+
maxImpactDepth: { type: "integer", minimum: 1, maximum: 10 },
|
|
920
|
+
},
|
|
921
|
+
} as never,
|
|
922
|
+
async execute(
|
|
923
|
+
_id: string,
|
|
924
|
+
params: { task: string; reason?: string; maxImpactDepth?: number },
|
|
925
|
+
_signal,
|
|
926
|
+
_onUpdate,
|
|
927
|
+
ctx,
|
|
928
|
+
) {
|
|
929
|
+
const dir = projectDir(ctx);
|
|
930
|
+
const { list } = loadFeatureList(dir);
|
|
931
|
+
const target = flattenTasks(list).find(
|
|
932
|
+
(t) => t.compositeKey === params.task || t.key === params.task || t.id === params.task,
|
|
933
|
+
);
|
|
934
|
+
if (!target) {
|
|
935
|
+
return {
|
|
936
|
+
content: [{ type: "text", text: `No task matches "${params.task}".` }],
|
|
937
|
+
details: { error: "no-such-task" },
|
|
938
|
+
isError: true,
|
|
939
|
+
};
|
|
940
|
+
}
|
|
941
|
+
try {
|
|
942
|
+
const result = await startRework({
|
|
943
|
+
projectDir: dir,
|
|
944
|
+
featureId: target.featureId,
|
|
945
|
+
taskId: target.id,
|
|
946
|
+
key: target.key,
|
|
947
|
+
reason: params.reason ?? "rework requested",
|
|
948
|
+
runId,
|
|
949
|
+
maxImpactDepth: params.maxImpactDepth,
|
|
950
|
+
});
|
|
951
|
+
refreshWidget(ctx as ExtensionContext);
|
|
952
|
+
const downstream = result.impacted.length
|
|
953
|
+
? `Also flipped ${result.impacted.length} dependent task(s): ${result.impacted.join(", ")}`
|
|
954
|
+
: "Nothing depends on it, so this is contained.";
|
|
955
|
+
return {
|
|
956
|
+
content: [
|
|
957
|
+
{
|
|
958
|
+
type: "text",
|
|
959
|
+
text: `${target.compositeKey} is back in rework (plan revision ${result.baseRevision}).\n${downstream}\nFix the root task first, then re-prove the rest.`,
|
|
960
|
+
},
|
|
961
|
+
],
|
|
962
|
+
details: result,
|
|
963
|
+
};
|
|
964
|
+
} catch (e) {
|
|
965
|
+
return {
|
|
966
|
+
content: [{ type: "text", text: e instanceof Error ? e.message : String(e) }],
|
|
967
|
+
details: { error: "rework-failed" },
|
|
968
|
+
isError: true,
|
|
969
|
+
};
|
|
970
|
+
}
|
|
971
|
+
},
|
|
972
|
+
});
|
|
973
|
+
|
|
974
|
+
pi.registerTool({
|
|
975
|
+
name: "infinity_replan",
|
|
976
|
+
label: "Replan",
|
|
977
|
+
description:
|
|
978
|
+
"Add sprints, features or tasks to the plan mid-run, without resubmitting the whole task list. " +
|
|
979
|
+
"Use when the work turns out to need something that was never planned — the plan is the record, " +
|
|
980
|
+
"and building what it does not contain leaves it lying. Bounded by the replan budget.",
|
|
981
|
+
parameters: {
|
|
982
|
+
type: "object",
|
|
983
|
+
properties: {
|
|
984
|
+
reason: { type: "string", description: "What the plan was missing" },
|
|
985
|
+
addFeatures: {
|
|
986
|
+
type: "array",
|
|
987
|
+
maxItems: 20,
|
|
988
|
+
items: {
|
|
989
|
+
type: "object",
|
|
990
|
+
required: ["id", "name"],
|
|
991
|
+
properties: {
|
|
992
|
+
id: { type: "string" },
|
|
993
|
+
name: { type: "string" },
|
|
994
|
+
description: { type: "string" },
|
|
995
|
+
difficulty: { type: "string", enum: ["easy", "moderate", "difficult"] },
|
|
996
|
+
},
|
|
997
|
+
},
|
|
998
|
+
},
|
|
999
|
+
addTasks: {
|
|
1000
|
+
type: "array",
|
|
1001
|
+
maxItems: 50,
|
|
1002
|
+
items: {
|
|
1003
|
+
type: "object",
|
|
1004
|
+
required: ["featureId", "task"],
|
|
1005
|
+
properties: {
|
|
1006
|
+
featureId: { type: "string" },
|
|
1007
|
+
task: {
|
|
1008
|
+
type: "object",
|
|
1009
|
+
required: ["id", "description"],
|
|
1010
|
+
properties: {
|
|
1011
|
+
id: { type: "string" },
|
|
1012
|
+
key: { type: "string" },
|
|
1013
|
+
description: { type: "string" },
|
|
1014
|
+
status: { type: "string", enum: ["pending", "in_progress", "complete", "blocked", "rework"] },
|
|
1015
|
+
dependsOn: { type: "array", items: { type: "string" } },
|
|
1016
|
+
difficulty: { type: "string", enum: ["easy", "moderate", "difficult"] },
|
|
1017
|
+
acceptanceCriteria: { type: "array", items: { type: "string" } },
|
|
1018
|
+
},
|
|
1019
|
+
},
|
|
1020
|
+
},
|
|
1021
|
+
},
|
|
1022
|
+
},
|
|
1023
|
+
},
|
|
1024
|
+
} as never,
|
|
1025
|
+
async execute(
|
|
1026
|
+
_id: string,
|
|
1027
|
+
params: {
|
|
1028
|
+
reason?: string;
|
|
1029
|
+
addFeatures?: { id: string; name: string; description?: string; difficulty?: string }[];
|
|
1030
|
+
addTasks?: { featureId: string; task: ReplanTaskInput }[];
|
|
1031
|
+
},
|
|
1032
|
+
_signal,
|
|
1033
|
+
_onUpdate,
|
|
1034
|
+
ctx,
|
|
1035
|
+
) {
|
|
1036
|
+
const dir = projectDir(ctx);
|
|
1037
|
+
try {
|
|
1038
|
+
const result = await amendPlan({
|
|
1039
|
+
projectDir: dir,
|
|
1040
|
+
reason: params.reason ?? "mid-run amendment",
|
|
1041
|
+
addFeatures: params.addFeatures,
|
|
1042
|
+
addTasks: params.addTasks,
|
|
1043
|
+
});
|
|
1044
|
+
refreshWidget(ctx as ExtensionContext);
|
|
1045
|
+
return {
|
|
1046
|
+
content: [
|
|
1047
|
+
{
|
|
1048
|
+
type: "text",
|
|
1049
|
+
text:
|
|
1050
|
+
`Plan amended to revision ${result.baseRevision}: ` +
|
|
1051
|
+
`+${result.added.features} feature(s), +${result.added.tasks} task(s), ` +
|
|
1052
|
+
`+${result.added.sprints} sprint(s).`,
|
|
1053
|
+
},
|
|
1054
|
+
],
|
|
1055
|
+
details: result,
|
|
1056
|
+
};
|
|
1057
|
+
} catch (e) {
|
|
1058
|
+
return {
|
|
1059
|
+
content: [{ type: "text", text: e instanceof Error ? e.message : String(e) }],
|
|
1060
|
+
details: { error: "replan-failed" },
|
|
1061
|
+
isError: true,
|
|
1062
|
+
};
|
|
1063
|
+
}
|
|
1064
|
+
},
|
|
1065
|
+
});
|
|
1066
|
+
|
|
1067
|
+
pi.registerTool({
|
|
1068
|
+
name: "infinity_unstuck",
|
|
1069
|
+
label: "Unstuck",
|
|
1070
|
+
description:
|
|
1071
|
+
"Ask the escalation ladder what to try next: retry, reframe, consult a stronger model, rework, " +
|
|
1072
|
+
"replan, or master. Read-only — it recommends, it does not act. /infinity:run consults it " +
|
|
1073
|
+
"automatically when a run stalls; call it yourself when you are stuck and want the next rung.",
|
|
1074
|
+
parameters: { type: "object", properties: {} } as never,
|
|
1075
|
+
async execute(_id: string, _params: unknown, _signal, _onUpdate, ctx) {
|
|
1076
|
+
const dir = projectDir(ctx);
|
|
1077
|
+
const { list } = loadFeatureList(dir);
|
|
1078
|
+
const task = flattenTasks(list).find((t) => t.status === "in_progress" || t.status === "rework");
|
|
1079
|
+
const choice = chooseUnstuckStrategy({
|
|
1080
|
+
projectDir: dir,
|
|
1081
|
+
featureId: task?.featureId,
|
|
1082
|
+
taskId: task?.id,
|
|
1083
|
+
currentDifficulty: task?.difficulty ?? null,
|
|
1084
|
+
requireDeltaForRework: false,
|
|
1085
|
+
});
|
|
1086
|
+
const spent = escalationSummary(dir);
|
|
1087
|
+
const text = choice.strategy
|
|
1088
|
+
? `Next rung: ${choice.strategy} — ${choice.reason}` +
|
|
1089
|
+
(choice.nextModel ? `\nModel: ${choice.nextModel}` : "") +
|
|
1090
|
+
`\nSpent so far: ${spent.reworks} rework(s), ${spent.replans} replan(s)` +
|
|
1091
|
+
(spent.returnTo ? `, returning to ${spent.returnTo}` : "")
|
|
1092
|
+
: `The ladder has nothing left: ${choice.reason}. This needs a human.`;
|
|
1093
|
+
return { content: [{ type: "text", text }], details: { ...choice, spent } };
|
|
1094
|
+
},
|
|
1095
|
+
});
|
|
1096
|
+
|
|
1097
|
+
pi.registerTool({
|
|
1098
|
+
name: "infinity_spawn_worker",
|
|
1099
|
+
label: "Spawn Worker",
|
|
1100
|
+
description:
|
|
1101
|
+
"Run one task in an isolated worker: its own attempt directory, prompt, output log and " +
|
|
1102
|
+
"fingerprint under tmp/. Use for a task worth attempting without the current conversation's " +
|
|
1103
|
+
"context — a clean-room retry. Records the attempt whether or not a command is configured.",
|
|
1104
|
+
parameters: {
|
|
1105
|
+
type: "object",
|
|
1106
|
+
required: ["task", "prompt"],
|
|
1107
|
+
properties: {
|
|
1108
|
+
task: { type: "string", description: 'Task key, e.g. "feature-001/task-003"' },
|
|
1109
|
+
prompt: { type: "string", description: "The complete instruction for the isolated worker" },
|
|
1110
|
+
command: { type: "string", description: "Shell command to run; {promptfile} is substituted" },
|
|
1111
|
+
model: { type: "string", description: "Model reference for the worker" },
|
|
1112
|
+
timeoutMs: { type: "integer", minimum: 1000, maximum: 3_600_000 },
|
|
1113
|
+
},
|
|
1114
|
+
} as never,
|
|
1115
|
+
async execute(
|
|
1116
|
+
_id: string,
|
|
1117
|
+
params: { task: string; prompt: string; command?: string; model?: string; timeoutMs?: number },
|
|
1118
|
+
_signal,
|
|
1119
|
+
_onUpdate,
|
|
1120
|
+
ctx,
|
|
1121
|
+
) {
|
|
1122
|
+
const dir = projectDir(ctx);
|
|
1123
|
+
const { list } = loadFeatureList(dir);
|
|
1124
|
+
const target = flattenTasks(list).find(
|
|
1125
|
+
(t) => t.compositeKey === params.task || t.key === params.task || t.id === params.task,
|
|
1126
|
+
);
|
|
1127
|
+
if (!target) {
|
|
1128
|
+
return {
|
|
1129
|
+
content: [{ type: "text", text: `No task matches "${params.task}".` }],
|
|
1130
|
+
details: { error: "no-such-task" },
|
|
1131
|
+
isError: true,
|
|
1132
|
+
};
|
|
1133
|
+
}
|
|
1134
|
+
try {
|
|
1135
|
+
const result = await spawnIsolatedWorker({
|
|
1136
|
+
projectDir: dir,
|
|
1137
|
+
runId,
|
|
1138
|
+
featureId: target.featureId,
|
|
1139
|
+
taskId: target.id,
|
|
1140
|
+
prompt: params.prompt,
|
|
1141
|
+
command: params.command,
|
|
1142
|
+
model: params.model,
|
|
1143
|
+
timeoutMs: params.timeoutMs,
|
|
1144
|
+
});
|
|
1145
|
+
const ran = params.command
|
|
1146
|
+
? `exit ${result.exitCode}${result.timedOut ? " (timed out)" : ""}`
|
|
1147
|
+
: "recorded only — no command configured";
|
|
1148
|
+
return {
|
|
1149
|
+
content: [
|
|
1150
|
+
{
|
|
1151
|
+
type: "text",
|
|
1152
|
+
text:
|
|
1153
|
+
`Worker attempt ${result.attempt} for ${target.compositeKey}: ${ran}\n` +
|
|
1154
|
+
`${result.attemptDir}\n\n${result.output.slice(-4000) || "(no output)"}`,
|
|
1155
|
+
},
|
|
1156
|
+
],
|
|
1157
|
+
details: result,
|
|
1158
|
+
};
|
|
1159
|
+
} catch (e) {
|
|
1160
|
+
return {
|
|
1161
|
+
content: [{ type: "text", text: e instanceof Error ? e.message : String(e) }],
|
|
1162
|
+
details: { error: "worker-failed" },
|
|
1163
|
+
isError: true,
|
|
1164
|
+
};
|
|
1165
|
+
}
|
|
1166
|
+
},
|
|
1167
|
+
});
|
|
1168
|
+
|
|
1169
|
+
pi.registerTool({
|
|
1170
|
+
name: "infinity_goal",
|
|
1171
|
+
label: "Goal",
|
|
1172
|
+
description:
|
|
1173
|
+
"The outer loop. `start` states a goal and opens pass 1; `status` reports where it is; " +
|
|
1174
|
+
"`review` judges whether the work so far actually meets the goal and, if it does not, rewinds " +
|
|
1175
|
+
"the pipeline for another pass with the remaining work named; `cancel` stops pursuing it. " +
|
|
1176
|
+
"The phase gate decides whether the WORK is done; this decides whether the GOAL is done.",
|
|
1177
|
+
parameters: {
|
|
1178
|
+
type: "object",
|
|
1179
|
+
required: ["action"],
|
|
1180
|
+
properties: {
|
|
1181
|
+
action: { type: "string", enum: ["start", "status", "review", "cancel"] },
|
|
1182
|
+
goal: { type: "string", description: "start: what this whole run is for, in one sentence" },
|
|
1183
|
+
maxIterations: { type: "integer", minimum: 1, maximum: 50, description: "start: how many passes at most" },
|
|
1184
|
+
decision: {
|
|
1185
|
+
type: "string",
|
|
1186
|
+
enum: ["complete", "incomplete", "blocked", "failed"],
|
|
1187
|
+
description: "review: does the work meet the goal?",
|
|
1188
|
+
},
|
|
1189
|
+
rationale: { type: "string", description: "review: why, judged against the goal not the plan" },
|
|
1190
|
+
remainingWork: {
|
|
1191
|
+
type: "array",
|
|
1192
|
+
items: { type: "string" },
|
|
1193
|
+
description: "review: required unless complete — what is still missing. The next pass is planned from this.",
|
|
1194
|
+
},
|
|
1195
|
+
reason: { type: "string", description: "cancel: why" },
|
|
1196
|
+
},
|
|
1197
|
+
} as never,
|
|
1198
|
+
async execute(
|
|
1199
|
+
_id: string,
|
|
1200
|
+
params: {
|
|
1201
|
+
action: string;
|
|
1202
|
+
goal?: string;
|
|
1203
|
+
maxIterations?: number;
|
|
1204
|
+
decision?: ReviewInput["decision"];
|
|
1205
|
+
rationale?: string;
|
|
1206
|
+
remainingWork?: string[];
|
|
1207
|
+
reason?: string;
|
|
1208
|
+
},
|
|
1209
|
+
_signal,
|
|
1210
|
+
_onUpdate,
|
|
1211
|
+
ctx,
|
|
1212
|
+
) {
|
|
1213
|
+
const dir = projectDir(ctx);
|
|
1214
|
+
try {
|
|
1215
|
+
if (params.action === "start") {
|
|
1216
|
+
if (!params.goal?.trim()) {
|
|
1217
|
+
return {
|
|
1218
|
+
content: [{ type: "text", text: "A goal needs to say something." }],
|
|
1219
|
+
details: { error: "no-goal" },
|
|
1220
|
+
isError: true,
|
|
1221
|
+
};
|
|
1222
|
+
}
|
|
1223
|
+
const { state } = await startGoal({
|
|
1224
|
+
targetDir: dir,
|
|
1225
|
+
goal: params.goal,
|
|
1226
|
+
runId: `goal-${runId}`,
|
|
1227
|
+
maxIterations: params.maxIterations,
|
|
1228
|
+
});
|
|
1229
|
+
refreshWidget(ctx as ExtensionContext);
|
|
1230
|
+
const view = viewOf(state);
|
|
1231
|
+
return {
|
|
1232
|
+
content: [
|
|
1233
|
+
{
|
|
1234
|
+
type: "text",
|
|
1235
|
+
text:
|
|
1236
|
+
`Goal set: ${view.goal}\nPass 1 of at most ${view.maxIterations}. The pipeline is at the ` +
|
|
1237
|
+
`first phase — define what this needs, plan it, build it. When the pipeline completes, ` +
|
|
1238
|
+
`call infinity_goal with action "review" and judge it against the goal, not the plan.`,
|
|
1239
|
+
},
|
|
1240
|
+
],
|
|
1241
|
+
details: view,
|
|
1242
|
+
};
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
if (params.action === "status") {
|
|
1246
|
+
const state = await loadGoal(dir);
|
|
1247
|
+
if (!state) {
|
|
1248
|
+
return {
|
|
1249
|
+
content: [{ type: "text", text: "No goal is being pursued in this project." }],
|
|
1250
|
+
details: { active: false },
|
|
1251
|
+
};
|
|
1252
|
+
}
|
|
1253
|
+
const view = viewOf(state);
|
|
1254
|
+
const remaining = view.remainingWork.length
|
|
1255
|
+
? `\nStill missing:\n${view.remainingWork.map((w) => ` - ${w}`).join("\n")}`
|
|
1256
|
+
: "";
|
|
1257
|
+
return {
|
|
1258
|
+
content: [{ type: "text", text: `${describeGoal(view)}\nPhase: ${view.phase}${remaining}` }],
|
|
1259
|
+
details: view,
|
|
1260
|
+
};
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
if (params.action === "review") {
|
|
1264
|
+
if (!params.decision || !params.rationale?.trim()) {
|
|
1265
|
+
return {
|
|
1266
|
+
content: [{ type: "text", text: "A review needs a decision and a rationale." }],
|
|
1267
|
+
details: { error: "incomplete-review" },
|
|
1268
|
+
isError: true,
|
|
1269
|
+
};
|
|
1270
|
+
}
|
|
1271
|
+
const outcome = await reviewGoal(dir, {
|
|
1272
|
+
decision: params.decision,
|
|
1273
|
+
rationale: params.rationale,
|
|
1274
|
+
remainingWork: params.remainingWork,
|
|
1275
|
+
});
|
|
1276
|
+
refreshWidget(ctx as ExtensionContext);
|
|
1277
|
+
if (!outcome.terminal) {
|
|
1278
|
+
// Rewinding the pipeline means the next brief is a different one.
|
|
1279
|
+
pi.sendUserMessage(await briefText(dir), { deliverAs: "followUp" });
|
|
1280
|
+
}
|
|
1281
|
+
return { content: [{ type: "text", text: outcome.message }], details: viewOf(outcome.state) };
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
if (params.action === "cancel") {
|
|
1285
|
+
const state = await cancelGoal(dir, params.reason ?? "cancelled by request");
|
|
1286
|
+
refreshWidget(ctx as ExtensionContext);
|
|
1287
|
+
return {
|
|
1288
|
+
content: [{ type: "text", text: state ? `Goal cancelled: ${state.goal}` : "No goal to cancel." }],
|
|
1289
|
+
details: state ? viewOf(state) : { active: false },
|
|
1290
|
+
};
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
return {
|
|
1294
|
+
content: [{ type: "text", text: `Unknown action "${params.action}".` }],
|
|
1295
|
+
details: { error: "unknown-action" },
|
|
1296
|
+
isError: true,
|
|
1297
|
+
};
|
|
1298
|
+
} catch (e) {
|
|
1299
|
+
return {
|
|
1300
|
+
content: [{ type: "text", text: e instanceof Error ? e.message : String(e) }],
|
|
1301
|
+
details: { error: "goal-failed" },
|
|
1302
|
+
isError: true,
|
|
1303
|
+
};
|
|
1304
|
+
}
|
|
1305
|
+
},
|
|
1306
|
+
});
|
|
1307
|
+
|
|
681
1308
|
// -- commands -------------------------------------------------------------
|
|
682
1309
|
|
|
683
1310
|
pi.registerCommand("infinity:status", {
|
|
@@ -685,7 +1312,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
685
1312
|
handler: async (_args: string, ctx: ExtensionContext) => {
|
|
686
1313
|
const dir = projectDir(ctx);
|
|
687
1314
|
if (!isHarnessProject(dir)) {
|
|
688
|
-
notify(ctx,
|
|
1315
|
+
notify(ctx, NO_HARNESS, "warning");
|
|
689
1316
|
return;
|
|
690
1317
|
}
|
|
691
1318
|
const state = widgetStateFor(dir);
|
|
@@ -725,7 +1352,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
725
1352
|
handler: async (_args: string, ctx: ExtensionContext) => {
|
|
726
1353
|
const dir = projectDir(ctx);
|
|
727
1354
|
if (!isHarnessProject(dir)) {
|
|
728
|
-
notify(ctx,
|
|
1355
|
+
notify(ctx, NO_HARNESS, "warning");
|
|
729
1356
|
return;
|
|
730
1357
|
}
|
|
731
1358
|
loopEnabled = true;
|
|
@@ -740,6 +1367,140 @@ export default function (pi: ExtensionAPI): void {
|
|
|
740
1367
|
},
|
|
741
1368
|
});
|
|
742
1369
|
|
|
1370
|
+
pi.registerCommand("infinity:goal", {
|
|
1371
|
+
description: "State a goal and pursue it across passes — or review, cancel, or check one",
|
|
1372
|
+
handler: async (args: string, ctx: ExtensionContext) => {
|
|
1373
|
+
const dir = projectDir(ctx);
|
|
1374
|
+
if (!isHarnessProject(dir)) {
|
|
1375
|
+
notify(ctx, NO_HARNESS, "warning");
|
|
1376
|
+
return;
|
|
1377
|
+
}
|
|
1378
|
+
const text = args.trim();
|
|
1379
|
+
|
|
1380
|
+
if (text === "" || text === "status") {
|
|
1381
|
+
const state = await loadGoal(dir);
|
|
1382
|
+
if (!state) {
|
|
1383
|
+
notify(ctx, 'No goal set. `/infinity:goal <what you want built>` starts one.', "info");
|
|
1384
|
+
return;
|
|
1385
|
+
}
|
|
1386
|
+
const view = viewOf(state);
|
|
1387
|
+
const remaining = view.remainingWork.length
|
|
1388
|
+
? `\nStill missing:\n${view.remainingWork.map((w) => ` - ${w}`).join("\n")}`
|
|
1389
|
+
: "";
|
|
1390
|
+
notify(ctx, `${describeGoal(view)}\nPhase: ${view.phase}${remaining}`, "info");
|
|
1391
|
+
return;
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
if (text === "cancel") {
|
|
1395
|
+
const state = await cancelGoal(dir, "cancelled from /infinity:goal");
|
|
1396
|
+
notify(ctx, state ? `Goal cancelled: ${state.goal}` : "No goal to cancel.", "info");
|
|
1397
|
+
refreshWidget(ctx);
|
|
1398
|
+
return;
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
try {
|
|
1402
|
+
const { state } = await startGoal({ targetDir: dir, goal: text, runId: `goal-${runId}` });
|
|
1403
|
+
refreshWidget(ctx);
|
|
1404
|
+
notify(
|
|
1405
|
+
ctx,
|
|
1406
|
+
`Goal set: ${state.goal}\nPass 1 of at most ${state.limits.maxIterations}. ` +
|
|
1407
|
+
`The pipeline is back at its first phase.`,
|
|
1408
|
+
"info",
|
|
1409
|
+
);
|
|
1410
|
+
pi.sendUserMessage(await briefText(dir), { deliverAs: "followUp" });
|
|
1411
|
+
} catch (e) {
|
|
1412
|
+
notify(ctx, e instanceof Error ? e.message : String(e), "error");
|
|
1413
|
+
}
|
|
1414
|
+
},
|
|
1415
|
+
});
|
|
1416
|
+
|
|
1417
|
+
pi.registerCommand("infinity:unstuck", {
|
|
1418
|
+
description: "What the escalation ladder would try next, and what it has spent",
|
|
1419
|
+
handler: async (_args: string, ctx: ExtensionContext) => {
|
|
1420
|
+
const dir = projectDir(ctx);
|
|
1421
|
+
if (!isHarnessProject(dir)) {
|
|
1422
|
+
notify(ctx, NO_HARNESS, "warning");
|
|
1423
|
+
return;
|
|
1424
|
+
}
|
|
1425
|
+
const { list } = loadFeatureList(dir);
|
|
1426
|
+
const task = flattenTasks(list).find((t) => t.status === "in_progress" || t.status === "rework");
|
|
1427
|
+
const choice = chooseUnstuckStrategy({
|
|
1428
|
+
projectDir: dir,
|
|
1429
|
+
featureId: task?.featureId,
|
|
1430
|
+
taskId: task?.id,
|
|
1431
|
+
currentDifficulty: task?.difficulty ?? null,
|
|
1432
|
+
requireDeltaForRework: false,
|
|
1433
|
+
});
|
|
1434
|
+
const spent = escalationSummary(dir);
|
|
1435
|
+
const rework = loadRework(dir);
|
|
1436
|
+
const lines = [
|
|
1437
|
+
choice.strategy
|
|
1438
|
+
? `Next rung: ${choice.strategy} — ${choice.reason}`
|
|
1439
|
+
: `The ladder has nothing left: ${choice.reason}`,
|
|
1440
|
+
choice.nextModel ? `Model: ${choice.nextModel}` : null,
|
|
1441
|
+
`Spent: ${spent.reworks} rework(s), ${spent.replans} replan(s)`,
|
|
1442
|
+
rework ? `Returning to ${rework.returnFeature}/${rework.returnTask} — ${rework.reason}` : null,
|
|
1443
|
+
`Ladder: ${DEFAULT_LADDER.join(" → ")}`,
|
|
1444
|
+
].filter((l): l is string => l !== null);
|
|
1445
|
+
notify(ctx, lines.join("\n"), "info");
|
|
1446
|
+
},
|
|
1447
|
+
});
|
|
1448
|
+
|
|
1449
|
+
pi.registerCommand("infinity:rework", {
|
|
1450
|
+
description: "Send a task and its dependents back to rework",
|
|
1451
|
+
handler: async (args: string, ctx: ExtensionContext) => {
|
|
1452
|
+
const dir = projectDir(ctx);
|
|
1453
|
+
if (!isHarnessProject(dir)) {
|
|
1454
|
+
notify(ctx, NO_HARNESS, "warning");
|
|
1455
|
+
return;
|
|
1456
|
+
}
|
|
1457
|
+
const key = args.trim();
|
|
1458
|
+
const { list } = loadFeatureList(dir);
|
|
1459
|
+
const tasks = flattenTasks(list);
|
|
1460
|
+
|
|
1461
|
+
if (key === "clear") {
|
|
1462
|
+
await clearRework(dir);
|
|
1463
|
+
notify(ctx, "Rework record cleared.", "info");
|
|
1464
|
+
refreshWidget(ctx);
|
|
1465
|
+
return;
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
let target = tasks.find((t) => t.compositeKey === key || t.key === key || t.id === key);
|
|
1469
|
+
if (!target && ctx.hasUI) {
|
|
1470
|
+
const rows = tasks.map((t) => `${t.compositeKey} [${t.status}] ${t.description}`);
|
|
1471
|
+
const picked = await ctx.ui.select("Send which task back to rework?", rows);
|
|
1472
|
+
if (picked === undefined) return;
|
|
1473
|
+
target = tasks[rows.indexOf(picked)];
|
|
1474
|
+
}
|
|
1475
|
+
if (!target) {
|
|
1476
|
+
notify(ctx, key ? `No task matches "${key}".` : "Name a task: /infinity:rework <task-key>", "warning");
|
|
1477
|
+
return;
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
try {
|
|
1481
|
+
const result = await startRework({
|
|
1482
|
+
projectDir: dir,
|
|
1483
|
+
featureId: target.featureId,
|
|
1484
|
+
taskId: target.id,
|
|
1485
|
+
key: target.key,
|
|
1486
|
+
reason: "rework from /infinity:rework",
|
|
1487
|
+
runId,
|
|
1488
|
+
});
|
|
1489
|
+
refreshWidget(ctx);
|
|
1490
|
+
notify(
|
|
1491
|
+
ctx,
|
|
1492
|
+
`${target.compositeKey} → rework (revision ${result.baseRevision}). ` +
|
|
1493
|
+
(result.impacted.length
|
|
1494
|
+
? `${result.impacted.length} dependent task(s) went with it: ${result.impacted.join(", ")}`
|
|
1495
|
+
: "Nothing depends on it."),
|
|
1496
|
+
"info",
|
|
1497
|
+
);
|
|
1498
|
+
} catch (e) {
|
|
1499
|
+
notify(ctx, e instanceof Error ? e.message : String(e), "error");
|
|
1500
|
+
}
|
|
1501
|
+
},
|
|
1502
|
+
});
|
|
1503
|
+
|
|
743
1504
|
pi.registerCommand("infinity:halt", {
|
|
744
1505
|
description: "Stop the continuous loop after the current turn",
|
|
745
1506
|
handler: async (_args: string, ctx: ExtensionContext) => {
|
|
@@ -784,7 +1545,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
784
1545
|
handler: async (args: string, ctx: ExtensionContext) => {
|
|
785
1546
|
const dir = projectDir(ctx);
|
|
786
1547
|
if (!isHarnessProject(dir)) {
|
|
787
|
-
notify(ctx,
|
|
1548
|
+
notify(ctx, NO_HARNESS, "warning");
|
|
788
1549
|
return;
|
|
789
1550
|
}
|
|
790
1551
|
|