omegon 0.7.8 → 0.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.
- package/extensions/cleave/dispatcher.ts +1 -1
- package/extensions/cleave/index.ts +1 -0
- package/extensions/cleave/workspace.ts +4 -3
- package/extensions/dashboard/footer.ts +84 -21
- package/extensions/design-tree/index.ts +87 -56
- package/extensions/design-tree/tree.ts +113 -0
- package/extensions/lib/shared-state.ts +24 -0
- package/extensions/openspec/index.ts +25 -0
- package/extensions/project-memory/factstore.ts +176 -63
- package/extensions/project-memory/index.ts +91 -0
- package/package.json +1 -1
|
@@ -294,7 +294,7 @@ export function buildChildPrompt(
|
|
|
294
294
|
" - Fill in Summary, Artifacts, Decisions Made, Interfaces Published",
|
|
295
295
|
"3. **Commits**: Commit your work with clear messages. Do not push.",
|
|
296
296
|
"4. **No side effects**: Do not install global packages or modify system state.",
|
|
297
|
-
"5. **
|
|
297
|
+
"5. **Testing**: Write tests for new functions and changed behavior in co-located *.test.ts files. Run them and report results in the Verification section. Untested code is incomplete.",
|
|
298
298
|
`6. **Workspace**: ${workspacePath}`,
|
|
299
299
|
];
|
|
300
300
|
|
|
@@ -1583,6 +1583,7 @@ export default function cleaveExtension(pi: ExtensionAPI) {
|
|
|
1583
1583
|
promptSnippet:
|
|
1584
1584
|
"Assess task complexity for decomposition — returns pattern match, complexity score, and execute/cleave decision",
|
|
1585
1585
|
promptGuidelines: [
|
|
1586
|
+
"Every non-trivial code change must include tests in co-located *.test.ts files. Untested code is incomplete — do not commit without tests for new functions and changed behavior.",
|
|
1586
1587
|
"Call cleave_assess before starting any multi-system or cross-cutting task to determine if decomposition is needed",
|
|
1587
1588
|
"If decision is 'execute', proceed directly. If 'cleave', use /cleave to decompose. If 'needs_assessment', proceed directly — it means no pattern matched but the task is likely simple enough for in-session execution.",
|
|
1588
1589
|
"Complexity formula: (1 + systems) × (1 + 0.5 × modifiers). Threshold default: 2.0.",
|
|
@@ -238,9 +238,10 @@ ${skillSection}${designSection}${guardrailSection ?? ""}
|
|
|
238
238
|
## Contract
|
|
239
239
|
|
|
240
240
|
1. Only work on files within your scope
|
|
241
|
-
2.
|
|
242
|
-
3.
|
|
243
|
-
4.
|
|
241
|
+
2. Write tests for new functions and changed behavior — co-locate as *.test.ts
|
|
242
|
+
3. Update the Result section below when done
|
|
243
|
+
4. Commit your work with clear messages — do not push
|
|
244
|
+
5. If the task is too complex, set status to NEEDS_DECOMPOSITION
|
|
244
245
|
|
|
245
246
|
## Result
|
|
246
247
|
|
|
@@ -546,27 +546,33 @@ export class DashboardFooter implements Component {
|
|
|
546
546
|
// ── Provider / model / thinking line ──────────────────────
|
|
547
547
|
const m = ctx.model;
|
|
548
548
|
if (m) {
|
|
549
|
-
const multiProvider = this.footerData.getAvailableProviderCount() > 1;
|
|
550
549
|
const pointer = theme.fg("accent", "▸");
|
|
551
|
-
const dot = theme.fg("dim", "
|
|
550
|
+
const dot = theme.fg("dim", " · ");
|
|
552
551
|
|
|
552
|
+
// Width-aware: narrow cards drop provider prefix, abbreviate thinking
|
|
553
|
+
const narrow = width < 40;
|
|
554
|
+
const multiProvider = !narrow && this.footerData.getAvailableProviderCount() > 1;
|
|
555
|
+
|
|
556
|
+
// Shorten model ID for display: "claude-opus-4-6" stays, but drop provider/ prefix if present in ID
|
|
557
|
+
const modelDisplay = m.id.replace(/^.*\//, "");
|
|
553
558
|
const providerModel = multiProvider
|
|
554
|
-
? `${pointer} ${theme.fg("muted", m.provider)}
|
|
555
|
-
: `${pointer} ${theme.fg("muted",
|
|
559
|
+
? `${pointer} ${theme.fg("muted", m.provider)}${theme.fg("dim", "/")}${theme.fg("muted", modelDisplay)}`
|
|
560
|
+
: `${pointer} ${theme.fg("muted", modelDisplay)}`;
|
|
556
561
|
|
|
557
562
|
const parts: string[] = [providerModel];
|
|
558
563
|
|
|
559
|
-
if (m.reasoning) {
|
|
564
|
+
if (m.reasoning && this.cachedThinkingLevel) {
|
|
560
565
|
const thinkColor: ThemeColor = this.cachedThinkingLevel === "high" ? "accent"
|
|
561
566
|
: this.cachedThinkingLevel === "medium" ? "muted"
|
|
562
567
|
: "dim";
|
|
563
568
|
const thinkIcon = this.cachedThinkingLevel === "off"
|
|
564
569
|
? theme.fg("dim", "○")
|
|
565
570
|
: theme.fg(thinkColor, "◉");
|
|
566
|
-
|
|
571
|
+
// Narrow: icon only, no label
|
|
572
|
+
parts.push(narrow ? thinkIcon : `${thinkIcon} ${theme.fg(thinkColor, this.cachedThinkingLevel)}`);
|
|
567
573
|
}
|
|
568
574
|
|
|
569
|
-
if (this.cachedTokens.cost > 0) {
|
|
575
|
+
if (this.cachedTokens.cost > 0 && !narrow) {
|
|
570
576
|
parts.push(theme.fg("dim", `$${this.cachedTokens.cost.toFixed(3)}`));
|
|
571
577
|
}
|
|
572
578
|
|
|
@@ -592,7 +598,8 @@ export class DashboardFooter implements Component {
|
|
|
592
598
|
|
|
593
599
|
if (!metrics && totalFacts === null) return "";
|
|
594
600
|
|
|
595
|
-
const sep = theme.fg("dim", "
|
|
601
|
+
const sep = theme.fg("dim", " · ");
|
|
602
|
+
const narrow = width < 35;
|
|
596
603
|
const parts: string[] = [];
|
|
597
604
|
|
|
598
605
|
if (totalFacts !== null) {
|
|
@@ -600,17 +607,24 @@ export class DashboardFooter implements Component {
|
|
|
600
607
|
}
|
|
601
608
|
|
|
602
609
|
if (metrics) {
|
|
610
|
+
// Always show injection count and token estimate
|
|
603
611
|
if (metrics.projectFactCount > 0)
|
|
604
612
|
parts.push(theme.fg("dim", "inj ") + theme.fg("muted", String(metrics.projectFactCount)));
|
|
605
|
-
|
|
613
|
+
// Show working memory and episodes only when there's room
|
|
614
|
+
if (!narrow && metrics.workingMemoryFactCount > 0)
|
|
606
615
|
parts.push(theme.fg("dim", "wm ") + theme.fg("muted", String(metrics.workingMemoryFactCount)));
|
|
607
|
-
if (metrics.episodeCount > 0)
|
|
616
|
+
if (!narrow && metrics.episodeCount > 0)
|
|
608
617
|
parts.push(theme.fg("dim", "ep ") + theme.fg("muted", String(metrics.episodeCount)));
|
|
609
|
-
|
|
618
|
+
// Drop global count in narrow — least useful
|
|
619
|
+
if (!narrow && metrics.globalFactCount > 0)
|
|
610
620
|
parts.push(theme.fg("dim", "gl ") + theme.fg("muted", String(metrics.globalFactCount)));
|
|
611
|
-
|
|
621
|
+
// Token estimate — always show, use compact format
|
|
622
|
+
const tokStr = metrics.estimatedTokens >= 1000
|
|
623
|
+
? `~${(metrics.estimatedTokens / 1000).toFixed(1)}k`
|
|
624
|
+
: `~${metrics.estimatedTokens}`;
|
|
625
|
+
parts.push(theme.fg("dim", tokStr));
|
|
612
626
|
} else {
|
|
613
|
-
parts.push(theme.fg("dim", "pending
|
|
627
|
+
parts.push(theme.fg("dim", "pending"));
|
|
614
628
|
}
|
|
615
629
|
|
|
616
630
|
return truncateToWidth(` ${parts.join(sep)}`, width, "…");
|
|
@@ -674,6 +688,41 @@ export class DashboardFooter implements Component {
|
|
|
674
688
|
return lines;
|
|
675
689
|
}
|
|
676
690
|
|
|
691
|
+
/**
|
|
692
|
+
* Derive a short directive label from the active mind name.
|
|
693
|
+
* "directive/my-feature" → "my-feature", null/default → null.
|
|
694
|
+
*/
|
|
695
|
+
private getDirectiveLabel(): string | null {
|
|
696
|
+
const mind = sharedState.activeMind;
|
|
697
|
+
if (!mind || mind === "default") return null;
|
|
698
|
+
// Strip common prefixes: "directive/", "mind/"
|
|
699
|
+
const label = mind.replace(/^(?:directive|mind)\//, "");
|
|
700
|
+
return label || null;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
/**
|
|
704
|
+
* Build directive indicator lines for the model card area.
|
|
705
|
+
* Shows "▸ directive: my-feature ✓" (branch match) or "▸ directive: my-feature ⚠ main" (mismatch).
|
|
706
|
+
* Returns empty array when no directive is active.
|
|
707
|
+
*/
|
|
708
|
+
private buildDirectiveIndicatorLines(width: number): string[] {
|
|
709
|
+
const label = this.getDirectiveLabel();
|
|
710
|
+
if (!label) return [];
|
|
711
|
+
const theme = this.theme;
|
|
712
|
+
const directive = sharedState.activeDirective;
|
|
713
|
+
const currentBranch = this.footerData.getGitBranch?.() ?? "";
|
|
714
|
+
|
|
715
|
+
let branchBadge = "";
|
|
716
|
+
if (directive && currentBranch) {
|
|
717
|
+
branchBadge = currentBranch === directive.branch
|
|
718
|
+
? ` ${theme.fg("success", "✓")}`
|
|
719
|
+
: ` ${theme.fg("error", "⚠")} ${theme.fg("dim", currentBranch)}`;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
const line = `${theme.fg("warning", "▸")} ${theme.fg("dim", "directive:")} ${theme.fg("warning", label)}${branchBadge}`;
|
|
723
|
+
return [truncateToWidth(line, width, "…")];
|
|
724
|
+
}
|
|
725
|
+
|
|
677
726
|
private buildModelTopologySummaries(): DashboardModelRoleSummary[] {
|
|
678
727
|
const ctx = this.ctxRef;
|
|
679
728
|
const summaries: DashboardModelRoleSummary[] = [];
|
|
@@ -751,8 +800,12 @@ export class DashboardFooter implements Component {
|
|
|
751
800
|
: theme.fg("dim", summary.state));
|
|
752
801
|
const normalized = normalizeLocalModelLabel(summary.model);
|
|
753
802
|
const alias = forceCompact ? "" : (normalized.alias ? theme.fg("dim", `alias ${normalized.alias}`) : "");
|
|
754
|
-
|
|
755
|
-
const
|
|
803
|
+
// In very compact mode, drop the role label to give more room to the model name
|
|
804
|
+
const veryCompact = width < 30;
|
|
805
|
+
const roleLabel = veryCompact ? "" : (forceCompact ? summary.label.slice(0, 1) : summary.label);
|
|
806
|
+
const primary = roleLabel
|
|
807
|
+
? `${theme.fg("accent", roleLabel)} ${theme.fg("muted", normalized.canonical)}`
|
|
808
|
+
: theme.fg("muted", normalized.canonical);
|
|
756
809
|
return truncateToWidth(
|
|
757
810
|
composePrimaryMetaLine(
|
|
758
811
|
width,
|
|
@@ -790,7 +843,10 @@ export class DashboardFooter implements Component {
|
|
|
790
843
|
contextCard: this.buildSummaryCard("context", this.buildHudContextLines(Math.max(1, safeWidth - 2)).map((l) => l.trimStart()), safeWidth),
|
|
791
844
|
modelCard: this.buildSummaryCard(
|
|
792
845
|
"models",
|
|
793
|
-
|
|
846
|
+
[
|
|
847
|
+
...this.buildDirectiveIndicatorLines(Math.max(1, safeWidth - 2)),
|
|
848
|
+
...this.buildModelTopologySummaries().map((s) => this.formatModelTopologyLine(s, Math.max(1, safeWidth - 2), safeWidth < 44)),
|
|
849
|
+
],
|
|
794
850
|
safeWidth,
|
|
795
851
|
),
|
|
796
852
|
memoryCard: this.buildSummaryCard("memory", (() => {
|
|
@@ -852,7 +908,10 @@ export class DashboardFooter implements Component {
|
|
|
852
908
|
contextCard: this.buildSummaryCardForColumn("context", this.buildHudContextLines(Math.max(1, col1W - 2)).map((l) => l.trimStart()), col1W, col1W),
|
|
853
909
|
modelCard: this.buildSummaryCardForColumn(
|
|
854
910
|
"models",
|
|
855
|
-
|
|
911
|
+
[
|
|
912
|
+
...this.buildDirectiveIndicatorLines(Math.max(1, col2W - 2)),
|
|
913
|
+
...this.buildModelTopologySummaries().map((s) => this.formatModelTopologyLine(s, Math.max(1, col2W - 2), col2W < 44)),
|
|
914
|
+
],
|
|
856
915
|
col2W,
|
|
857
916
|
col2W,
|
|
858
917
|
),
|
|
@@ -1074,10 +1133,14 @@ export class DashboardFooter implements Component {
|
|
|
1074
1133
|
}
|
|
1075
1134
|
}
|
|
1076
1135
|
|
|
1077
|
-
// If no focused node and no implementing nodes, show
|
|
1136
|
+
// If no focused node and no implementing nodes, show actionable nodes only.
|
|
1137
|
+
// Filter out decided/implemented/resolved — those are done; the footer should
|
|
1138
|
+
// surface work in progress, not a historical catalog.
|
|
1078
1139
|
if (!dt.focusedNode && (!dt.implementingNodes || dt.implementingNodes.length === 0) && dt.nodes) {
|
|
1140
|
+
const TERMINAL_STATUSES = new Set(["decided", "implemented", "resolved"]);
|
|
1141
|
+
const actionable = dt.nodes.filter(n => !TERMINAL_STATUSES.has(n.status));
|
|
1079
1142
|
const MAX_NODES = 6;
|
|
1080
|
-
for (const n of
|
|
1143
|
+
for (const n of actionable.slice(0, MAX_NODES)) {
|
|
1081
1144
|
const icon = this.nodeStatusIcon(n.status);
|
|
1082
1145
|
const badge = designSpecBadge(n.designSpec, n.assessmentResult, (c, t) => theme.fg(c as any, t));
|
|
1083
1146
|
const badgeSep = badge ? " " : "";
|
|
@@ -1090,9 +1153,9 @@ export class DashboardFooter implements Component {
|
|
|
1090
1153
|
[qSuffix + linkSuffix],
|
|
1091
1154
|
));
|
|
1092
1155
|
}
|
|
1093
|
-
if (
|
|
1156
|
+
if (actionable.length > MAX_NODES) {
|
|
1094
1157
|
lines.push(
|
|
1095
|
-
theme.fg("dim", ` … ${
|
|
1158
|
+
theme.fg("dim", ` … ${actionable.length - MAX_NODES} more`) +
|
|
1096
1159
|
theme.fg("dim", " — /dashboard to expand"),
|
|
1097
1160
|
);
|
|
1098
1161
|
}
|
|
@@ -59,6 +59,7 @@ import {
|
|
|
59
59
|
validateNodeId,
|
|
60
60
|
scaffoldOpenSpecChange,
|
|
61
61
|
scaffoldDesignOpenSpecChange,
|
|
62
|
+
extractAndArchiveDesignSpec,
|
|
62
63
|
mirrorOpenQuestionsToDesignSpec,
|
|
63
64
|
matchBranchToNode,
|
|
64
65
|
appendBranch,
|
|
@@ -798,31 +799,44 @@ export default function designTreeExtension(pi: ExtensionAPI): void {
|
|
|
798
799
|
}
|
|
799
800
|
const oldStatus = node.status;
|
|
800
801
|
|
|
801
|
-
//
|
|
802
|
-
//
|
|
803
|
-
//
|
|
802
|
+
// Gate: set_status(decided) checks substance first, then artifacts.
|
|
803
|
+
// Substance = open questions resolved, decisions recorded.
|
|
804
|
+
// Artifacts = design spec archived (auto-created if missing).
|
|
804
805
|
if (newStatus === "decided") {
|
|
806
|
+
const openQs = node.open_questions?.length ?? 0;
|
|
807
|
+
|
|
808
|
+
// Substance check: open questions must be resolved (fast — no file I/O)
|
|
809
|
+
if (openQs > 0) {
|
|
810
|
+
return {
|
|
811
|
+
content: [{ type: "text", text:
|
|
812
|
+
`⚠ Cannot decide '${node.title}': ${openQs} open question${openQs > 1 ? "s" : ""} remain\n` +
|
|
813
|
+
`→ Resolve with add_decision/remove_question, or branch child nodes for exploration` }],
|
|
814
|
+
details: { id: node.id, blockedBy: "open-questions", openQuestions: openQs },
|
|
815
|
+
isError: true,
|
|
816
|
+
};
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
// Substance check: at least one decision recorded (skip for lightweight types)
|
|
820
|
+
// Parse doc only if we need to check decisions (past the open-questions gate).
|
|
821
|
+
const sections = getNodeSections(node);
|
|
805
822
|
const lightweightTypes = new Set(["bug", "chore", "task"]);
|
|
806
823
|
const isLightweight = node.issue_type && lightweightTypes.has(node.issue_type);
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
details: { id: node.id, blockedBy: "design-openspec-not-archived" },
|
|
824
|
-
isError: true,
|
|
825
|
-
};
|
|
824
|
+
if (!isLightweight && sections.decisions.length === 0) {
|
|
825
|
+
return {
|
|
826
|
+
content: [{ type: "text", text:
|
|
827
|
+
`⚠ Cannot decide '${node.title}': no decisions recorded\n` +
|
|
828
|
+
`→ Use add_decision to record at least one design decision before marking decided` }],
|
|
829
|
+
details: { id: node.id, blockedBy: "no-decisions" },
|
|
830
|
+
isError: true,
|
|
831
|
+
};
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
// Artifact check: auto-scaffold + auto-archive if missing
|
|
835
|
+
const designSpec = resolveDesignSpecBinding(ctx.cwd, node.id);
|
|
836
|
+
if (!designSpec.archived) {
|
|
837
|
+
const extracted = extractAndArchiveDesignSpec(ctx.cwd, node);
|
|
838
|
+
if (extracted.created) {
|
|
839
|
+
// Auto-scaffolded — proceed (message appended below)
|
|
826
840
|
}
|
|
827
841
|
}
|
|
828
842
|
}
|
|
@@ -945,10 +959,19 @@ export default function designTreeExtension(pi: ExtensionAPI): void {
|
|
|
945
959
|
if (!node) {
|
|
946
960
|
return { content: [{ type: "text", text: `Node '${params.node_id}' not found` }], details: {}, isError: true };
|
|
947
961
|
}
|
|
948
|
-
|
|
962
|
+
// Auto-transition seed → exploring on first substance addition
|
|
963
|
+
let transitioned = "";
|
|
964
|
+
let currentNode = node;
|
|
965
|
+
if (node.status === "seed") {
|
|
966
|
+
currentNode = setNodeStatus(node, "exploring");
|
|
967
|
+
tree.nodes.set(currentNode.id, currentNode);
|
|
968
|
+
scaffoldDesignOpenSpecChange(ctx.cwd, currentNode);
|
|
969
|
+
transitioned = " (auto-transitioned seed → exploring)";
|
|
970
|
+
}
|
|
971
|
+
addResearch(currentNode, params.heading, params.content);
|
|
949
972
|
emitCurrentState();
|
|
950
973
|
return {
|
|
951
|
-
content: [{ type: "text", text: `Added research '${params.heading}' to '${node.title}'` }],
|
|
974
|
+
content: [{ type: "text", text: `Added research '${params.heading}' to '${node.title}'${transitioned}` }],
|
|
952
975
|
details: { id: node.id, heading: params.heading },
|
|
953
976
|
};
|
|
954
977
|
}
|
|
@@ -962,13 +985,20 @@ export default function designTreeExtension(pi: ExtensionAPI): void {
|
|
|
962
985
|
if (!node) {
|
|
963
986
|
return { content: [{ type: "text", text: `Node '${params.node_id}' not found` }], details: {}, isError: true };
|
|
964
987
|
}
|
|
988
|
+
// Auto-transition seed → exploring on first substance addition
|
|
989
|
+
let currentNode = node;
|
|
990
|
+
if (node.status === "seed") {
|
|
991
|
+
currentNode = setNodeStatus(node, "exploring");
|
|
992
|
+
tree.nodes.set(currentNode.id, currentNode);
|
|
993
|
+
scaffoldDesignOpenSpecChange(ctx.cwd, currentNode);
|
|
994
|
+
}
|
|
965
995
|
const validDecisionStatuses = ["exploring", "decided", "rejected"];
|
|
966
996
|
const rawDStatus = params.decision_status || "exploring";
|
|
967
997
|
if (!validDecisionStatuses.includes(rawDStatus)) {
|
|
968
998
|
return { content: [{ type: "text", text: `Invalid decision_status '${rawDStatus}'. Valid: ${validDecisionStatuses.join(", ")}` }], details: {}, isError: true };
|
|
969
999
|
}
|
|
970
1000
|
const dStatus = rawDStatus as "exploring" | "decided" | "rejected";
|
|
971
|
-
addDecision(
|
|
1001
|
+
addDecision(currentNode, {
|
|
972
1002
|
title: params.decision_title,
|
|
973
1003
|
status: dStatus,
|
|
974
1004
|
rationale: params.rationale || "",
|
|
@@ -1136,43 +1166,25 @@ export default function designTreeExtension(pi: ExtensionAPI): void {
|
|
|
1136
1166
|
return { content: [{ type: "text", text: `Node '${params.node_id}' not found` }], details: {}, isError: true };
|
|
1137
1167
|
}
|
|
1138
1168
|
if (node.status !== "decided" && node.status !== "resolved") {
|
|
1169
|
+
const openQs = node.open_questions?.length ?? 0;
|
|
1170
|
+
const hint = openQs > 0
|
|
1171
|
+
? `→ Resolve ${openQs} open question${openQs > 1 ? "s" : ""}, then run set_status(decided)`
|
|
1172
|
+
: `→ Run set_status(decided) to advance this node`;
|
|
1139
1173
|
return {
|
|
1140
|
-
content: [{
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
`Resolve open questions and set status to 'decided' (or 'resolved') before implementing.`,
|
|
1144
|
-
}],
|
|
1145
|
-
details: {},
|
|
1174
|
+
content: [{ type: "text", text:
|
|
1175
|
+
`⚠ Cannot implement '${node.title}': status is '${node.status}', needs 'decided' or 'resolved'\n${hint}` }],
|
|
1176
|
+
details: { id: node.id, blockedBy: "status", currentStatus: node.status },
|
|
1146
1177
|
isError: true,
|
|
1147
1178
|
};
|
|
1148
1179
|
}
|
|
1149
1180
|
|
|
1150
|
-
//
|
|
1151
|
-
//
|
|
1152
|
-
//
|
|
1153
|
-
// spec because the decision is the bug diagnosis itself.
|
|
1181
|
+
// Design-spec gate: if not yet archived, auto-extract from doc.
|
|
1182
|
+
// The decided gate already handles substance checks — implement
|
|
1183
|
+
// only needs the artifact to exist for audit trail.
|
|
1154
1184
|
{
|
|
1155
|
-
const
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
const skipDesignGate = isLightweight && !hasOpenQuestions && (node.status === "decided" || node.status === "resolved");
|
|
1159
|
-
|
|
1160
|
-
if (!skipDesignGate) {
|
|
1161
|
-
const designSpec = resolveDesignSpecBinding(ctx.cwd, node.id);
|
|
1162
|
-
if (designSpec.missing) {
|
|
1163
|
-
return {
|
|
1164
|
-
content: [{ type: "text", text: "Scaffold design spec first via set_status(exploring)" }],
|
|
1165
|
-
details: { id: node.id, blockedBy: "design-openspec-missing" },
|
|
1166
|
-
isError: true,
|
|
1167
|
-
};
|
|
1168
|
-
}
|
|
1169
|
-
if (designSpec.active && !designSpec.archived) {
|
|
1170
|
-
return {
|
|
1171
|
-
content: [{ type: "text", text: `Cannot implement '${node.title}': archive the design change first (\`/opsx:archive\` on the design change).` }],
|
|
1172
|
-
details: { id: node.id, blockedBy: "design-openspec-not-archived" },
|
|
1173
|
-
isError: true,
|
|
1174
|
-
};
|
|
1175
|
-
}
|
|
1185
|
+
const designSpec = resolveDesignSpecBinding(ctx.cwd, node.id);
|
|
1186
|
+
if (!designSpec.archived) {
|
|
1187
|
+
extractAndArchiveDesignSpec(ctx.cwd, node);
|
|
1176
1188
|
}
|
|
1177
1189
|
}
|
|
1178
1190
|
|
|
@@ -1180,6 +1192,25 @@ export default function designTreeExtension(pi: ExtensionAPI): void {
|
|
|
1180
1192
|
reload(ctx.cwd);
|
|
1181
1193
|
emitCurrentState();
|
|
1182
1194
|
|
|
1195
|
+
// Auto-focus the design node and fork a directive-scoped memory mind
|
|
1196
|
+
// so context injection and fact isolation track the active directive.
|
|
1197
|
+
if (implResult.ok) {
|
|
1198
|
+
// Auto-focus so context injection immediately tracks this node
|
|
1199
|
+
focusedNode = node.id;
|
|
1200
|
+
|
|
1201
|
+
// Publish active directive for session-start and dashboard
|
|
1202
|
+
sharedState.activeDirective = {
|
|
1203
|
+
nodeId: node.id,
|
|
1204
|
+
branch: implResult.branch ?? `feature/${node.id}`,
|
|
1205
|
+
};
|
|
1206
|
+
|
|
1207
|
+
const mindName = `directive/${node.id}`;
|
|
1208
|
+
(sharedState.mindLifecycleQueue ??= []).push(
|
|
1209
|
+
{ action: "fork", mind: mindName, description: `Memory scope for ${implResult.branch ?? node.id}` },
|
|
1210
|
+
{ action: "activate", mind: mindName },
|
|
1211
|
+
);
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1183
1214
|
return {
|
|
1184
1215
|
content: [{ type: "text", text: implResult.message }],
|
|
1185
1216
|
details: implResult.ok
|
|
@@ -1605,3 +1605,116 @@ export function mirrorOpenQuestionsToDesignSpec(cwd: string, node: DesignNode):
|
|
|
1605
1605
|
const content = buildDesignTasksContent(node, syntheticSections as DocumentSections);
|
|
1606
1606
|
fs.writeFileSync(tasksPath, content);
|
|
1607
1607
|
}
|
|
1608
|
+
|
|
1609
|
+
/**
|
|
1610
|
+
* Extract a design-spec artifact from the doc's structured content.
|
|
1611
|
+
* Deterministic: no LLM pass, no placeholders. If the doc has thin content,
|
|
1612
|
+
* the extracted spec reflects that honestly.
|
|
1613
|
+
*
|
|
1614
|
+
* Creates openspec/design/{id}/ with proposal.md, spec.md, and archives it
|
|
1615
|
+
* immediately to openspec/design-archive/{date}-{id}/.
|
|
1616
|
+
*
|
|
1617
|
+
* Returns { created, archived, message }.
|
|
1618
|
+
*/
|
|
1619
|
+
export function extractAndArchiveDesignSpec(
|
|
1620
|
+
cwd: string,
|
|
1621
|
+
node: DesignNode,
|
|
1622
|
+
): { created: boolean; archived: boolean; message: string } {
|
|
1623
|
+
const designDir = path.join(cwd, "openspec", "design", node.id);
|
|
1624
|
+
const archiveBaseDir = path.join(cwd, "openspec", "design-archive");
|
|
1625
|
+
|
|
1626
|
+
// Already archived? Nothing to do.
|
|
1627
|
+
if (fs.existsSync(archiveBaseDir)) {
|
|
1628
|
+
for (const entry of fs.readdirSync(archiveBaseDir, { withFileTypes: true })) {
|
|
1629
|
+
if (!entry.isDirectory()) continue;
|
|
1630
|
+
const match = entry.name.match(/^\d{4}-\d{2}-\d{2}-(.+)$/);
|
|
1631
|
+
if (match && match[1] === node.id) {
|
|
1632
|
+
return { created: false, archived: true, message: "Design spec already archived" };
|
|
1633
|
+
}
|
|
1634
|
+
}
|
|
1635
|
+
}
|
|
1636
|
+
|
|
1637
|
+
let sections: DocumentSections;
|
|
1638
|
+
try {
|
|
1639
|
+
sections = getNodeSections(node);
|
|
1640
|
+
} catch {
|
|
1641
|
+
sections = {
|
|
1642
|
+
overview: "",
|
|
1643
|
+
research: [],
|
|
1644
|
+
decisions: [],
|
|
1645
|
+
openQuestions: node.open_questions ?? [],
|
|
1646
|
+
implementationNotes: { fileScope: [], constraints: [], rawContent: "" },
|
|
1647
|
+
acceptanceCriteria: { scenarios: [], falsifiability: [], constraints: [] },
|
|
1648
|
+
extraSections: [],
|
|
1649
|
+
};
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1652
|
+
// ── Build spec content from doc sections ──────────────────────
|
|
1653
|
+
const specLines: string[] = [
|
|
1654
|
+
`# ${node.title} — Design Spec (extracted)`,
|
|
1655
|
+
"",
|
|
1656
|
+
`> Auto-extracted from docs/${node.id}.md at decide-time.`,
|
|
1657
|
+
"",
|
|
1658
|
+
];
|
|
1659
|
+
|
|
1660
|
+
if (sections.decisions.length > 0) {
|
|
1661
|
+
specLines.push("## Decisions", "");
|
|
1662
|
+
for (const d of sections.decisions) {
|
|
1663
|
+
specLines.push(`### ${d.title} (${d.status})`);
|
|
1664
|
+
if (d.rationale) specLines.push("", d.rationale);
|
|
1665
|
+
specLines.push("");
|
|
1666
|
+
}
|
|
1667
|
+
}
|
|
1668
|
+
|
|
1669
|
+
const ac = sections.acceptanceCriteria;
|
|
1670
|
+
if (ac.scenarios.length > 0 || ac.falsifiability.length > 0 || ac.constraints.length > 0) {
|
|
1671
|
+
specLines.push("## Acceptance Criteria", "");
|
|
1672
|
+
if (ac.scenarios.length > 0) {
|
|
1673
|
+
specLines.push("### Scenarios", "");
|
|
1674
|
+
for (const s of ac.scenarios) specLines.push(`- ${s}`);
|
|
1675
|
+
specLines.push("");
|
|
1676
|
+
}
|
|
1677
|
+
if (ac.falsifiability.length > 0) {
|
|
1678
|
+
specLines.push("### Falsifiability", "");
|
|
1679
|
+
for (const f of ac.falsifiability) specLines.push(`- ${f}`);
|
|
1680
|
+
specLines.push("");
|
|
1681
|
+
}
|
|
1682
|
+
if (ac.constraints.length > 0) {
|
|
1683
|
+
specLines.push("### Constraints", "");
|
|
1684
|
+
for (const c of ac.constraints) specLines.push(`- ${c}`);
|
|
1685
|
+
specLines.push("");
|
|
1686
|
+
}
|
|
1687
|
+
}
|
|
1688
|
+
|
|
1689
|
+
if (sections.research.length > 0) {
|
|
1690
|
+
specLines.push("## Research Summary", "");
|
|
1691
|
+
for (const r of sections.research) {
|
|
1692
|
+
specLines.push(`### ${r.heading}`, "", r.content.slice(0, 500) + (r.content.length > 500 ? "…" : ""), "");
|
|
1693
|
+
}
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1696
|
+
// ── Write to openspec/design/{id}/ ──────────────────────────
|
|
1697
|
+
// Preserve any existing files (e.g. tasks.md from scaffold), overwrite proposal/spec.
|
|
1698
|
+
fs.mkdirSync(designDir, { recursive: true });
|
|
1699
|
+
fs.writeFileSync(
|
|
1700
|
+
path.join(designDir, "proposal.md"),
|
|
1701
|
+
`# ${node.title}\n\n## Intent\n\n${sections.overview?.split("\n").find(l => l.trim()) ?? node.title}\n\nSee [design doc](../../../docs/${node.id}.md).\n`,
|
|
1702
|
+
);
|
|
1703
|
+
fs.writeFileSync(path.join(designDir, "spec.md"), specLines.join("\n"));
|
|
1704
|
+
|
|
1705
|
+
// ── Immediately archive (copy ALL files, not just ours) ──────
|
|
1706
|
+
const today = new Date().toISOString().split("T")[0];
|
|
1707
|
+
const archiveDir = path.join(archiveBaseDir, `${today}-${node.id}`);
|
|
1708
|
+
fs.mkdirSync(archiveDir, { recursive: true });
|
|
1709
|
+
|
|
1710
|
+
for (const file of fs.readdirSync(designDir)) {
|
|
1711
|
+
fs.copyFileSync(path.join(designDir, file), path.join(archiveDir, file));
|
|
1712
|
+
}
|
|
1713
|
+
fs.rmSync(designDir, { recursive: true, force: true });
|
|
1714
|
+
|
|
1715
|
+
return {
|
|
1716
|
+
created: true,
|
|
1717
|
+
archived: true,
|
|
1718
|
+
message: `Extracted design spec from docs/${node.id}.md (${sections.decisions.length} decisions, ${sections.research.length} research sections) → archived to openspec/design-archive/${today}-${node.id}/`,
|
|
1719
|
+
};
|
|
1720
|
+
}
|
|
@@ -135,6 +135,30 @@ interface SharedState {
|
|
|
135
135
|
/** Set by bootstrap when first-run is detected. Other extensions should suppress
|
|
136
136
|
* redundant "no providers" warnings when this is true — bootstrap handles guidance. */
|
|
137
137
|
bootstrapPending?: boolean;
|
|
138
|
+
|
|
139
|
+
/** Pending mind lifecycle operations from design-tree/openspec for project-memory to process.
|
|
140
|
+
* Written by implement/archive flows, consumed by project-memory on next turn. */
|
|
141
|
+
mindLifecycleQueue?: MindLifecycleRequest[];
|
|
142
|
+
|
|
143
|
+
/** Active directive: the design node currently being implemented.
|
|
144
|
+
* Set by design_tree_update(implement), read by session-start and dashboard. */
|
|
145
|
+
activeDirective?: { nodeId: string; branch: string } | null;
|
|
146
|
+
|
|
147
|
+
/** Active mind name from factstore. Written by project-memory, read by dashboard.
|
|
148
|
+
* null means default mind (no directive active). */
|
|
149
|
+
activeMind?: string | null;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export type MindLifecycleAction = "fork" | "activate" | "ingest" | "delete";
|
|
153
|
+
|
|
154
|
+
export interface MindLifecycleRequest {
|
|
155
|
+
action: MindLifecycleAction;
|
|
156
|
+
/** Mind name: fork target, activate target, ingest source, or delete target. */
|
|
157
|
+
mind: string;
|
|
158
|
+
/** Human-readable description (used by fork). */
|
|
159
|
+
description?: string;
|
|
160
|
+
/** Target mind for ingest (defaults to 'default'). */
|
|
161
|
+
targetMind?: string;
|
|
138
162
|
}
|
|
139
163
|
|
|
140
164
|
// Initialize once on first import, reuse thereafter via global symbol.
|
|
@@ -764,6 +764,20 @@ export default function openspecExtension(pi: ExtensionAPI): void {
|
|
|
764
764
|
);
|
|
765
765
|
}
|
|
766
766
|
|
|
767
|
+
// Merge directive-scoped memory mind back to default and clean up.
|
|
768
|
+
// The mind was forked at implement time; ingest merges discoveries
|
|
769
|
+
// back (deduplicating) and delete removes the scope.
|
|
770
|
+
// Queue unconditionally — the drain skips gracefully if the mind
|
|
771
|
+
// doesn't exist (e.g. untracked changes that skipped implement).
|
|
772
|
+
{
|
|
773
|
+
const mindName = `directive/${params.change_name}`;
|
|
774
|
+
(sharedState.mindLifecycleQueue ??= []).push(
|
|
775
|
+
{ action: "ingest", mind: mindName, targetMind: "default" },
|
|
776
|
+
{ action: "activate", mind: "default" },
|
|
777
|
+
{ action: "delete", mind: mindName },
|
|
778
|
+
);
|
|
779
|
+
}
|
|
780
|
+
|
|
767
781
|
// Auto-delete merged feature branches from transitioned design nodes
|
|
768
782
|
const allBranches = resolveBoundDesignNodes(cwd, params.change_name)
|
|
769
783
|
.flatMap((n) => n.branches ?? []);
|
|
@@ -1763,6 +1777,17 @@ export default function openspecExtension(pi: ExtensionAPI): void {
|
|
|
1763
1777
|
);
|
|
1764
1778
|
}
|
|
1765
1779
|
|
|
1780
|
+
// Merge directive-scoped memory mind back to default and clean up.
|
|
1781
|
+
// Queue unconditionally — the drain skips if the mind doesn't exist.
|
|
1782
|
+
{
|
|
1783
|
+
const mindName = `directive/${changeName}`;
|
|
1784
|
+
(sharedState.mindLifecycleQueue ??= []).push(
|
|
1785
|
+
{ action: "ingest", mind: mindName, targetMind: "default" },
|
|
1786
|
+
{ action: "activate", mind: "default" },
|
|
1787
|
+
{ action: "delete", mind: mindName },
|
|
1788
|
+
);
|
|
1789
|
+
}
|
|
1790
|
+
|
|
1766
1791
|
// Auto-delete merged feature branches from transitioned design nodes
|
|
1767
1792
|
const allBranches = resolveBoundDesignNodes(ctx.cwd, changeName)
|
|
1768
1793
|
.flatMap((n) => n.branches ?? []);
|
|
@@ -555,10 +555,13 @@ export class FactStore {
|
|
|
555
555
|
const source = opts.source ?? "manual";
|
|
556
556
|
const content = opts.content.replace(/^-\s*/, "").trim();
|
|
557
557
|
|
|
558
|
-
// Dedup check — same mind, same hash, still active
|
|
558
|
+
// Dedup check — same mind (or parent chain), same hash, still active.
|
|
559
|
+
// Check the full chain so directive minds don't duplicate parent facts.
|
|
560
|
+
const chain = this.resolveMindChain(mind);
|
|
561
|
+
const dedupPlaceholders = chain.map(() => "?").join(", ");
|
|
559
562
|
const existing = this.db.prepare(
|
|
560
|
-
`SELECT id FROM facts WHERE mind
|
|
561
|
-
).get(
|
|
563
|
+
`SELECT id FROM facts WHERE mind IN (${dedupPlaceholders}) AND content_hash = ? AND status = 'active'`
|
|
564
|
+
).get(...chain, hash);
|
|
562
565
|
|
|
563
566
|
if (existing) {
|
|
564
567
|
// Reinforce the existing fact instead of duplicating
|
|
@@ -646,15 +649,19 @@ export class FactStore {
|
|
|
646
649
|
let added = 0;
|
|
647
650
|
const newFactIds: string[] = [];
|
|
648
651
|
|
|
652
|
+
// Resolve chain once for all observe dedup checks (cached, but avoid repeated map/join)
|
|
653
|
+
const observeChain = this.resolveMindChain(mind);
|
|
654
|
+
const observePlaceholders = observeChain.map(() => "?").join(", ");
|
|
655
|
+
|
|
649
656
|
const tx = this.db.transaction(() => {
|
|
650
657
|
for (const action of actions) {
|
|
651
658
|
switch (action.type) {
|
|
652
659
|
case "observe": {
|
|
653
|
-
// Fact observed in session — reinforce if exists, add if new
|
|
660
|
+
// Fact observed in session — reinforce if exists (in mind or parent chain), add if new
|
|
654
661
|
const hash = contentHash(action.content ?? "");
|
|
655
662
|
const existing = this.db.prepare(
|
|
656
|
-
`SELECT id FROM facts WHERE mind
|
|
657
|
-
).get(
|
|
663
|
+
`SELECT id FROM facts WHERE mind IN (${observePlaceholders}) AND content_hash = ? AND status = 'active'`
|
|
664
|
+
).get(...observeChain, hash);
|
|
658
665
|
|
|
659
666
|
if (existing) {
|
|
660
667
|
this.reinforceFact((existing as { id: string }).id);
|
|
@@ -743,7 +750,24 @@ export class FactStore {
|
|
|
743
750
|
* the minimumConfidence threshold is consistent with the confidence computation.
|
|
744
751
|
*/
|
|
745
752
|
sweepDecayedFacts(mind: string): number {
|
|
746
|
-
|
|
753
|
+
// Only sweep facts directly in this mind, not inherited from parents.
|
|
754
|
+
// Parent facts are managed by their own mind's sweep cycle.
|
|
755
|
+
const facts = this.db.prepare(
|
|
756
|
+
`SELECT * FROM facts WHERE mind = ? AND status = 'active' ORDER BY section, created_at`
|
|
757
|
+
).all(mind) as Fact[];
|
|
758
|
+
// Apply decay (same logic as getActiveFacts)
|
|
759
|
+
const NO_DECAY_SECTIONS: readonly string[] = ["Specs"];
|
|
760
|
+
const now = Date.now();
|
|
761
|
+
for (const fact of facts) {
|
|
762
|
+
if (NO_DECAY_SECTIONS.includes(fact.section)) {
|
|
763
|
+
fact.confidence = 1.0;
|
|
764
|
+
} else {
|
|
765
|
+
const lastReinforced = new Date(fact.last_reinforced).getTime();
|
|
766
|
+
const daysSince = (now - lastReinforced) / (1000 * 60 * 60 * 24);
|
|
767
|
+
const profile = SECTION_DECAY_OVERRIDES[fact.section] ?? this.decayProfile;
|
|
768
|
+
fact.confidence = computeConfidence(daysSince, fact.reinforcement_count, profile);
|
|
769
|
+
}
|
|
770
|
+
}
|
|
747
771
|
let swept = 0;
|
|
748
772
|
|
|
749
773
|
for (const fact of facts) {
|
|
@@ -848,10 +872,12 @@ export class FactStore {
|
|
|
848
872
|
getActiveEdges(mind?: string): Edge[] {
|
|
849
873
|
let edges: Edge[];
|
|
850
874
|
if (mind) {
|
|
875
|
+
const chain = this.resolveMindChain(mind);
|
|
876
|
+
const placeholders = chain.map(() => "?").join(", ");
|
|
851
877
|
edges = this.db.prepare(`
|
|
852
878
|
SELECT * FROM edges
|
|
853
|
-
WHERE (source_mind
|
|
854
|
-
`).all(
|
|
879
|
+
WHERE (source_mind IN (${placeholders}) OR target_mind IN (${placeholders})) AND status = 'active'
|
|
880
|
+
`).all(...chain, ...chain) as Edge[];
|
|
855
881
|
} else {
|
|
856
882
|
edges = this.db.prepare(
|
|
857
883
|
`SELECT * FROM edges WHERE status = 'active'`
|
|
@@ -947,15 +973,65 @@ export class FactStore {
|
|
|
947
973
|
// Queries
|
|
948
974
|
// ---------------------------------------------------------------------------
|
|
949
975
|
|
|
976
|
+
private mindChainCache = new Map<string, string[]>();
|
|
977
|
+
|
|
978
|
+
/**
|
|
979
|
+
* Resolve the mind chain: [mind, parent, grandparent, ...].
|
|
980
|
+
* Used to include inherited facts from parent minds.
|
|
981
|
+
* Stops at 'default' or when no parent exists. Max depth 5 to prevent cycles.
|
|
982
|
+
* Cached per-mind since the parent chain is immutable within a session.
|
|
983
|
+
*/
|
|
984
|
+
private resolveMindChain(mind: string): string[] {
|
|
985
|
+
const cached = this.mindChainCache.get(mind);
|
|
986
|
+
if (cached) return cached;
|
|
987
|
+
|
|
988
|
+
const chain: string[] = [mind];
|
|
989
|
+
let current = mind;
|
|
990
|
+
for (let i = 0; i < 5; i++) {
|
|
991
|
+
const rec = this.getMind(current);
|
|
992
|
+
if (!rec?.parent || rec.parent === current) break;
|
|
993
|
+
chain.push(rec.parent);
|
|
994
|
+
current = rec.parent;
|
|
995
|
+
}
|
|
996
|
+
this.mindChainCache.set(mind, chain);
|
|
997
|
+
return chain;
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
/** Clear the mind chain cache (call after mind creation/deletion). */
|
|
1001
|
+
private invalidateMindChainCache(): void {
|
|
1002
|
+
this.mindChainCache.clear();
|
|
1003
|
+
}
|
|
1004
|
+
|
|
950
1005
|
/**
|
|
951
1006
|
* Get active facts for a mind, with confidence decay applied.
|
|
1007
|
+
* If the mind has a parent, includes inherited parent facts (deduped
|
|
1008
|
+
* by content_hash — child facts shadow parent facts with the same content).
|
|
952
1009
|
* Optionally limit to top N by confidence.
|
|
953
1010
|
*/
|
|
954
1011
|
getActiveFacts(mind: string, limit?: number): Fact[] {
|
|
955
|
-
const
|
|
956
|
-
|
|
1012
|
+
const chain = this.resolveMindChain(mind);
|
|
1013
|
+
const placeholders = chain.map(() => "?").join(", ");
|
|
1014
|
+
const allFacts = this.db.prepare(
|
|
1015
|
+
`SELECT * FROM facts WHERE mind IN (${placeholders}) AND status = 'active'
|
|
957
1016
|
ORDER BY section, created_at`
|
|
958
|
-
).all(
|
|
1017
|
+
).all(...chain) as Fact[];
|
|
1018
|
+
|
|
1019
|
+
// Deduplicate: child facts shadow parent facts with the same content_hash.
|
|
1020
|
+
// Keep the fact from the earliest mind in the chain (= the child).
|
|
1021
|
+
const seen = new Map<string, number>();
|
|
1022
|
+
const facts: Fact[] = [];
|
|
1023
|
+
for (const fact of allFacts) {
|
|
1024
|
+
const chainIdx = chain.indexOf(fact.mind);
|
|
1025
|
+
const existing = seen.get(fact.content_hash);
|
|
1026
|
+
if (existing !== undefined && existing <= chainIdx) continue; // child already present
|
|
1027
|
+
seen.set(fact.content_hash, chainIdx);
|
|
1028
|
+
// Remove any previously added parent fact with same hash
|
|
1029
|
+
if (existing !== undefined) {
|
|
1030
|
+
const idx = facts.findIndex(f => f.content_hash === fact.content_hash);
|
|
1031
|
+
if (idx !== -1) facts.splice(idx, 1);
|
|
1032
|
+
}
|
|
1033
|
+
facts.push(fact);
|
|
1034
|
+
}
|
|
959
1035
|
|
|
960
1036
|
// Apply time-based confidence decay.
|
|
961
1037
|
// Specs are exempt (binary exist/not-exist).
|
|
@@ -992,9 +1068,25 @@ export class FactStore {
|
|
|
992
1068
|
|
|
993
1069
|
/** Get active facts for a specific section, sorted by confidence descending. */
|
|
994
1070
|
getFactsBySection(mind: string, section: string): Fact[] {
|
|
995
|
-
const
|
|
996
|
-
|
|
997
|
-
|
|
1071
|
+
const chain = this.resolveMindChain(mind);
|
|
1072
|
+
const placeholders = chain.map(() => "?").join(", ");
|
|
1073
|
+
const allFacts = this.db.prepare(
|
|
1074
|
+
`SELECT * FROM facts WHERE mind IN (${placeholders}) AND section = ? AND status = 'active' ORDER BY created_at`
|
|
1075
|
+
).all(...chain, section) as Fact[];
|
|
1076
|
+
// Deduplicate: child facts shadow parent facts with the same content_hash.
|
|
1077
|
+
const seen = new Map<string, number>();
|
|
1078
|
+
const facts: Fact[] = [];
|
|
1079
|
+
for (const f of allFacts) {
|
|
1080
|
+
const chainIdx = chain.indexOf(f.mind);
|
|
1081
|
+
const existing = seen.get(f.content_hash);
|
|
1082
|
+
if (existing !== undefined && existing <= chainIdx) continue;
|
|
1083
|
+
seen.set(f.content_hash, chainIdx);
|
|
1084
|
+
if (existing !== undefined) {
|
|
1085
|
+
const idx = facts.findIndex(ff => ff.content_hash === f.content_hash);
|
|
1086
|
+
if (idx !== -1) facts.splice(idx, 1);
|
|
1087
|
+
}
|
|
1088
|
+
facts.push(f);
|
|
1089
|
+
}
|
|
998
1090
|
|
|
999
1091
|
const NO_DECAY_SECTIONS: readonly string[] = ["Specs"];
|
|
1000
1092
|
const now = Date.now();
|
|
@@ -1015,17 +1107,21 @@ export class FactStore {
|
|
|
1015
1107
|
|
|
1016
1108
|
/** Get the count of active facts per section for a mind. */
|
|
1017
1109
|
getSectionCounts(mind: string): Map<string, number> {
|
|
1110
|
+
const chain = this.resolveMindChain(mind);
|
|
1111
|
+
const placeholders = chain.map(() => "?").join(", ");
|
|
1018
1112
|
const rows = this.db.prepare(
|
|
1019
|
-
`SELECT section, COUNT(
|
|
1020
|
-
).all(
|
|
1113
|
+
`SELECT section, COUNT(DISTINCT content_hash) as count FROM facts WHERE mind IN (${placeholders}) AND status = 'active' GROUP BY section`
|
|
1114
|
+
).all(...chain) as { section: string; count: number }[];
|
|
1021
1115
|
return new Map(rows.map(r => [r.section, r.count]));
|
|
1022
1116
|
}
|
|
1023
1117
|
|
|
1024
|
-
/** Count active facts for a mind */
|
|
1118
|
+
/** Count active facts for a mind (including inherited parent facts) */
|
|
1025
1119
|
countActiveFacts(mind: string): number {
|
|
1120
|
+
const chain = this.resolveMindChain(mind);
|
|
1121
|
+
const placeholders = chain.map(() => "?").join(", ");
|
|
1026
1122
|
const row = this.db.prepare(
|
|
1027
|
-
`SELECT COUNT(
|
|
1028
|
-
).get(
|
|
1123
|
+
`SELECT COUNT(DISTINCT content_hash) as count FROM facts WHERE mind IN (${placeholders}) AND status = 'active'`
|
|
1124
|
+
).get(...chain);
|
|
1029
1125
|
return row?.count ?? 0;
|
|
1030
1126
|
}
|
|
1031
1127
|
|
|
@@ -1036,11 +1132,13 @@ export class FactStore {
|
|
|
1036
1132
|
const pattern = `${escaped}%`;
|
|
1037
1133
|
|
|
1038
1134
|
if (mind) {
|
|
1135
|
+
const chain = this.resolveMindChain(mind);
|
|
1136
|
+
const placeholders = chain.map(() => "?").join(", ");
|
|
1039
1137
|
return this.db.prepare(`
|
|
1040
1138
|
SELECT * FROM facts
|
|
1041
|
-
WHERE content LIKE ? ESCAPE '\\' AND mind
|
|
1139
|
+
WHERE content LIKE ? ESCAPE '\\' AND mind IN (${placeholders}) AND status = 'active'
|
|
1042
1140
|
ORDER BY created_at DESC
|
|
1043
|
-
`).all(pattern,
|
|
1141
|
+
`).all(pattern, ...chain) as Fact[];
|
|
1044
1142
|
}
|
|
1045
1143
|
|
|
1046
1144
|
return this.db.prepare(`
|
|
@@ -1057,12 +1155,14 @@ export class FactStore {
|
|
|
1057
1155
|
|
|
1058
1156
|
try {
|
|
1059
1157
|
if (mind) {
|
|
1158
|
+
const chain = this.resolveMindChain(mind);
|
|
1159
|
+
const placeholders = chain.map(() => "?").join(", ");
|
|
1060
1160
|
return this.db.prepare(`
|
|
1061
1161
|
SELECT f.* FROM facts f
|
|
1062
1162
|
JOIN facts_fts fts ON f.rowid = fts.rowid
|
|
1063
|
-
WHERE facts_fts MATCH ? AND f.mind
|
|
1163
|
+
WHERE facts_fts MATCH ? AND f.mind IN (${placeholders})
|
|
1064
1164
|
ORDER BY rank
|
|
1065
|
-
`).all(ftsQuery,
|
|
1165
|
+
`).all(ftsQuery, ...chain) as Fact[];
|
|
1066
1166
|
}
|
|
1067
1167
|
|
|
1068
1168
|
return this.db.prepare(`
|
|
@@ -1084,12 +1184,14 @@ export class FactStore {
|
|
|
1084
1184
|
|
|
1085
1185
|
try {
|
|
1086
1186
|
if (mind) {
|
|
1187
|
+
const chain = this.resolveMindChain(mind);
|
|
1188
|
+
const placeholders = chain.map(() => "?").join(", ");
|
|
1087
1189
|
return this.db.prepare(`
|
|
1088
1190
|
SELECT f.* FROM facts f
|
|
1089
1191
|
JOIN facts_fts fts ON f.rowid = fts.rowid
|
|
1090
|
-
WHERE facts_fts MATCH ? AND f.mind
|
|
1192
|
+
WHERE facts_fts MATCH ? AND f.mind IN (${placeholders}) AND f.status IN ('archived', 'superseded')
|
|
1091
1193
|
ORDER BY f.created_at DESC
|
|
1092
|
-
`).all(ftsQuery,
|
|
1194
|
+
`).all(ftsQuery, ...chain) as Fact[];
|
|
1093
1195
|
}
|
|
1094
1196
|
|
|
1095
1197
|
return this.db.prepare(`
|
|
@@ -1326,6 +1428,7 @@ export class FactStore {
|
|
|
1326
1428
|
this.db.prepare(`DELETE FROM minds WHERE name = ?`).run(name);
|
|
1327
1429
|
});
|
|
1328
1430
|
tx();
|
|
1431
|
+
this.invalidateMindChainCache();
|
|
1329
1432
|
}
|
|
1330
1433
|
|
|
1331
1434
|
/** Check if a mind exists */
|
|
@@ -1339,32 +1442,28 @@ export class FactStore {
|
|
|
1339
1442
|
return mind?.readonly === 1;
|
|
1340
1443
|
}
|
|
1341
1444
|
|
|
1342
|
-
/**
|
|
1445
|
+
/**
|
|
1446
|
+
* Fork a mind — create a child scope that inherits the parent's facts.
|
|
1447
|
+
*
|
|
1448
|
+
* Lightweight: creates the mind record with `parent` set but copies zero
|
|
1449
|
+
* facts, edges, or embeddings. Query methods (getActiveFacts, vector search)
|
|
1450
|
+
* automatically include parent facts via the parent chain. Only facts
|
|
1451
|
+
* explicitly stored in the child are scoped to it.
|
|
1452
|
+
*
|
|
1453
|
+
* On archive, `ingestMind` copies child-only facts back to the parent.
|
|
1454
|
+
*/
|
|
1343
1455
|
forkMind(sourceName: string, newName: string, description: string): void {
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
const facts = this.getActiveFacts(sourceName);
|
|
1348
|
-
const now = new Date().toISOString();
|
|
1349
|
-
|
|
1350
|
-
for (const fact of facts) {
|
|
1351
|
-
this.db.prepare(`
|
|
1352
|
-
INSERT INTO facts (id, mind, section, content, status, created_at, created_session,
|
|
1353
|
-
source, content_hash, confidence, last_reinforced,
|
|
1354
|
-
reinforcement_count, decay_rate)
|
|
1355
|
-
VALUES (?, ?, ?, ?, 'active', ?, NULL, 'ingest', ?, 1.0, ?, ?, ?)
|
|
1356
|
-
`).run(
|
|
1357
|
-
nanoid(), newName, fact.section, fact.content, now,
|
|
1358
|
-
fact.content_hash, now, fact.reinforcement_count, fact.decay_rate,
|
|
1359
|
-
);
|
|
1360
|
-
}
|
|
1361
|
-
});
|
|
1362
|
-
tx();
|
|
1456
|
+
this.createMind(newName, description, { parent: sourceName });
|
|
1457
|
+
this.invalidateMindChainCache();
|
|
1363
1458
|
}
|
|
1364
1459
|
|
|
1365
1460
|
/** Ingest facts from one mind into another */
|
|
1366
1461
|
ingestMind(sourceName: string, targetName: string): { factsIngested: number; duplicatesSkipped: number } {
|
|
1367
|
-
|
|
1462
|
+
// Only ingest facts directly stored in the source mind, not inherited from parents.
|
|
1463
|
+
// Inherited facts already exist in the parent (which is typically the target).
|
|
1464
|
+
const sourceFacts = this.db.prepare(
|
|
1465
|
+
`SELECT * FROM facts WHERE mind = ? AND status = 'active' ORDER BY section, created_at`
|
|
1466
|
+
).all(sourceName) as Fact[];
|
|
1368
1467
|
let ingested = 0;
|
|
1369
1468
|
let skipped = 0;
|
|
1370
1469
|
|
|
@@ -1750,13 +1849,15 @@ export class FactStore {
|
|
|
1750
1849
|
return rows.map(r => r.id);
|
|
1751
1850
|
}
|
|
1752
1851
|
|
|
1753
|
-
/** Count facts with vectors for a mind */
|
|
1852
|
+
/** Count facts with vectors for a mind (including parent chain) */
|
|
1754
1853
|
countFactVectors(mind: string): number {
|
|
1854
|
+
const chain = this.resolveMindChain(mind);
|
|
1855
|
+
const placeholders = chain.map(() => "?").join(", ");
|
|
1755
1856
|
const row = this.db.prepare(`
|
|
1756
1857
|
SELECT COUNT(*) as count FROM facts_vec v
|
|
1757
1858
|
JOIN facts f ON v.fact_id = f.id
|
|
1758
|
-
WHERE f.mind
|
|
1759
|
-
`).get(
|
|
1859
|
+
WHERE f.mind IN (${placeholders}) AND f.status = 'active'
|
|
1860
|
+
`).get(...chain);
|
|
1760
1861
|
return row?.count ?? 0;
|
|
1761
1862
|
}
|
|
1762
1863
|
|
|
@@ -1776,13 +1877,15 @@ export class FactStore {
|
|
|
1776
1877
|
const minSim = opts?.minSimilarity ?? 0.3;
|
|
1777
1878
|
const queryDims = queryVec.length;
|
|
1778
1879
|
|
|
1779
|
-
// Get all active facts with vectors for this mind
|
|
1880
|
+
// Get all active facts with vectors for this mind (including parent chain)
|
|
1881
|
+
const chain = this.resolveMindChain(mind);
|
|
1882
|
+
const placeholders = chain.map(() => "?").join(", ");
|
|
1780
1883
|
let query = `
|
|
1781
1884
|
SELECT f.*, v.embedding, v.dims FROM facts f
|
|
1782
1885
|
JOIN facts_vec v ON f.id = v.fact_id
|
|
1783
|
-
WHERE f.mind
|
|
1886
|
+
WHERE f.mind IN (${placeholders}) AND f.status = 'active'
|
|
1784
1887
|
`;
|
|
1785
|
-
const params: any[] = [
|
|
1888
|
+
const params: any[] = [...chain];
|
|
1786
1889
|
|
|
1787
1890
|
if (opts?.section) {
|
|
1788
1891
|
query += ` AND f.section = ?`;
|
|
@@ -1887,12 +1990,14 @@ export class FactStore {
|
|
|
1887
1990
|
const ftsQuery = buildSafeFtsQuery(queryText, "OR");
|
|
1888
1991
|
if (ftsQuery) {
|
|
1889
1992
|
try {
|
|
1993
|
+
const ftsChain = this.resolveMindChain(mind);
|
|
1994
|
+
const ftsPlaceholders = ftsChain.map(() => "?").join(", ");
|
|
1890
1995
|
let query = `
|
|
1891
1996
|
SELECT f.* FROM facts f
|
|
1892
1997
|
JOIN facts_fts fts ON f.rowid = fts.rowid
|
|
1893
|
-
WHERE facts_fts MATCH ? AND f.mind
|
|
1998
|
+
WHERE facts_fts MATCH ? AND f.mind IN (${ftsPlaceholders}) AND f.status = 'active'
|
|
1894
1999
|
`;
|
|
1895
|
-
const params: any[] = [ftsQuery,
|
|
2000
|
+
const params: any[] = [ftsQuery, ...ftsChain];
|
|
1896
2001
|
if (opts?.section) {
|
|
1897
2002
|
query += ` AND f.section = ?`;
|
|
1898
2003
|
params.push(opts.section);
|
|
@@ -1980,12 +2085,14 @@ export class FactStore {
|
|
|
1980
2085
|
const queryDims = queryVec.length;
|
|
1981
2086
|
const contentHashVal = contentHash(factContent);
|
|
1982
2087
|
|
|
2088
|
+
const chain = this.resolveMindChain(mind);
|
|
2089
|
+
const placeholders = chain.map(() => "?").join(", ");
|
|
1983
2090
|
const rows = this.db.prepare(`
|
|
1984
2091
|
SELECT f.*, v.embedding, v.dims FROM facts f
|
|
1985
2092
|
JOIN facts_vec v ON f.id = v.fact_id
|
|
1986
|
-
WHERE f.mind
|
|
2093
|
+
WHERE f.mind IN (${placeholders}) AND f.section = ? AND f.status = 'active'
|
|
1987
2094
|
AND f.content_hash != ?
|
|
1988
|
-
`).all(
|
|
2095
|
+
`).all(...chain, section, contentHashVal) as (Fact & { embedding: Buffer; dims: number })[];
|
|
1989
2096
|
|
|
1990
2097
|
const results: (Fact & { similarity: number })[] = [];
|
|
1991
2098
|
|
|
@@ -2071,9 +2178,11 @@ export class FactStore {
|
|
|
2071
2178
|
|
|
2072
2179
|
/** Get episodes for a mind, ordered by date descending */
|
|
2073
2180
|
getEpisodes(mind: string, limit?: number): Episode[] {
|
|
2074
|
-
const
|
|
2181
|
+
const chain = this.resolveMindChain(mind);
|
|
2182
|
+
const placeholders = chain.map(() => "?").join(", ");
|
|
2183
|
+
const sql = `SELECT * FROM episodes WHERE mind IN (${placeholders}) ORDER BY date DESC` +
|
|
2075
2184
|
(limit ? ` LIMIT ${limit}` : "");
|
|
2076
|
-
return this.db.prepare(sql).all(
|
|
2185
|
+
return this.db.prepare(sql).all(...chain) as Episode[];
|
|
2077
2186
|
}
|
|
2078
2187
|
|
|
2079
2188
|
/** Get a single episode by ID */
|
|
@@ -2119,11 +2228,13 @@ export class FactStore {
|
|
|
2119
2228
|
const minSim = opts?.minSimilarity ?? 0.3;
|
|
2120
2229
|
const queryDims = queryVec.length;
|
|
2121
2230
|
|
|
2231
|
+
const chain = this.resolveMindChain(mind);
|
|
2232
|
+
const ePlaceholders = chain.map(() => "?").join(", ");
|
|
2122
2233
|
const rows = this.db.prepare(`
|
|
2123
2234
|
SELECT e.*, v.embedding, v.dims FROM episodes e
|
|
2124
2235
|
JOIN episodes_vec v ON e.id = v.episode_id
|
|
2125
|
-
WHERE e.mind
|
|
2126
|
-
`).all(
|
|
2236
|
+
WHERE e.mind IN (${ePlaceholders})
|
|
2237
|
+
`).all(...chain) as (Episode & { embedding: Buffer; dims: number })[];
|
|
2127
2238
|
|
|
2128
2239
|
const results: (Episode & { similarity: number })[] = [];
|
|
2129
2240
|
|
|
@@ -2145,9 +2256,11 @@ export class FactStore {
|
|
|
2145
2256
|
|
|
2146
2257
|
/** Count episodes for a mind */
|
|
2147
2258
|
countEpisodes(mind: string): number {
|
|
2259
|
+
const chain = this.resolveMindChain(mind);
|
|
2260
|
+
const placeholders = chain.map(() => "?").join(", ");
|
|
2148
2261
|
const row = this.db.prepare(
|
|
2149
|
-
`SELECT COUNT(*) as count FROM episodes WHERE mind
|
|
2150
|
-
).get(
|
|
2262
|
+
`SELECT COUNT(*) as count FROM episodes WHERE mind IN (${placeholders})`
|
|
2263
|
+
).get(...chain);
|
|
2151
2264
|
return row?.count ?? 0;
|
|
2152
2265
|
}
|
|
2153
2266
|
|
|
@@ -660,6 +660,33 @@ export default function (pi: ExtensionAPI) {
|
|
|
660
660
|
// Best effort
|
|
661
661
|
}
|
|
662
662
|
|
|
663
|
+
// Drain mind lifecycle queue after store is initialized — fork/activate/ingest/delete
|
|
664
|
+
// requests from implement/archive flows that were queued before store existed.
|
|
665
|
+
drainMindLifecycleQueue(ctx);
|
|
666
|
+
|
|
667
|
+
// --- Branch↔mind consistency check for directive minds ---
|
|
668
|
+
// If the active mind is a directive mind (starts with 'directive/'),
|
|
669
|
+
// verify the current git branch matches the expected branch.
|
|
670
|
+
// Directive mind "directive/X" expects branch "feature/X".
|
|
671
|
+
const currentMind = activeMind();
|
|
672
|
+
if (currentMind.startsWith("directive/")) {
|
|
673
|
+
const directiveSuffix = currentMind.slice("directive/".length);
|
|
674
|
+
const expectedBranch = `feature/${directiveSuffix}`;
|
|
675
|
+
try {
|
|
676
|
+
const branchResult = await pi.exec("git", ["branch", "--show-current"], { timeout: 3_000, cwd: ctx.cwd });
|
|
677
|
+
const currentBranch = branchResult.stdout.trim();
|
|
678
|
+
if (currentBranch && currentBranch !== expectedBranch) {
|
|
679
|
+
pi.sendMessage({
|
|
680
|
+
customType: "directive-branch-mismatch",
|
|
681
|
+
content: `⚠️ Active directive mind "${currentMind}" expects branch "${expectedBranch}" but you are on "${currentBranch}". Run \`git checkout ${expectedBranch}\` to resume directive work.`,
|
|
682
|
+
display: false,
|
|
683
|
+
});
|
|
684
|
+
}
|
|
685
|
+
} catch {
|
|
686
|
+
// Git not available or not a git repo — skip check silently
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
|
|
663
690
|
triggerState = createTriggerState();
|
|
664
691
|
postCompaction = false;
|
|
665
692
|
firstTurn = true;
|
|
@@ -1339,6 +1366,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1339
1366
|
// --- Context Injection ---
|
|
1340
1367
|
|
|
1341
1368
|
pi.on("before_agent_start", async (event, ctx) => {
|
|
1369
|
+
drainMindLifecycleQueue(ctx);
|
|
1342
1370
|
drainLifecycleCandidateQueue(ctx);
|
|
1343
1371
|
drainFactArchiveQueue();
|
|
1344
1372
|
if (!store) return;
|
|
@@ -1904,6 +1932,66 @@ export default function (pi: ExtensionAPI) {
|
|
|
1904
1932
|
}
|
|
1905
1933
|
}
|
|
1906
1934
|
|
|
1935
|
+
function drainMindLifecycleQueue(ctx: ExtensionContext): void {
|
|
1936
|
+
if (!store) return;
|
|
1937
|
+
const queue = sharedState.mindLifecycleQueue ?? [];
|
|
1938
|
+
if (queue.length === 0) return;
|
|
1939
|
+
|
|
1940
|
+
sharedState.mindLifecycleQueue = [];
|
|
1941
|
+
|
|
1942
|
+
for (const req of queue) {
|
|
1943
|
+
try {
|
|
1944
|
+
switch (req.action) {
|
|
1945
|
+
case "fork": {
|
|
1946
|
+
if (store.mindExists(req.mind)) {
|
|
1947
|
+
// Already forked (e.g. resumed directive) — just skip
|
|
1948
|
+
break;
|
|
1949
|
+
}
|
|
1950
|
+
store.forkMind("default", req.mind, req.description ?? `Directive scope: ${req.mind}`);
|
|
1951
|
+
if (ctx.hasUI) {
|
|
1952
|
+
ctx.ui.notify(`[memory] Forked directive mind '${req.mind}'`, "info");
|
|
1953
|
+
}
|
|
1954
|
+
break;
|
|
1955
|
+
}
|
|
1956
|
+
case "activate": {
|
|
1957
|
+
const target = req.mind === "default" ? null : req.mind;
|
|
1958
|
+
if (target && !store.mindExists(target)) {
|
|
1959
|
+
// Mind doesn't exist (e.g. abandoned before archive) — stay on default
|
|
1960
|
+
break;
|
|
1961
|
+
}
|
|
1962
|
+
store.setActiveMind(target);
|
|
1963
|
+
break;
|
|
1964
|
+
}
|
|
1965
|
+
case "ingest": {
|
|
1966
|
+
const targetMind = req.targetMind ?? "default";
|
|
1967
|
+
if (!store.mindExists(req.mind)) {
|
|
1968
|
+
// Source mind doesn't exist — nothing to ingest
|
|
1969
|
+
break;
|
|
1970
|
+
}
|
|
1971
|
+
const result = store.ingestMind(req.mind, targetMind);
|
|
1972
|
+
if (ctx.hasUI) {
|
|
1973
|
+
ctx.ui.notify(
|
|
1974
|
+
`[memory] Merged '${req.mind}' → '${targetMind}': ${result.factsIngested} ingested, ${result.duplicatesSkipped} deduped`,
|
|
1975
|
+
"info",
|
|
1976
|
+
);
|
|
1977
|
+
}
|
|
1978
|
+
break;
|
|
1979
|
+
}
|
|
1980
|
+
case "delete": {
|
|
1981
|
+
if (req.mind === "default") break; // Safety: never delete default
|
|
1982
|
+
if (!store.mindExists(req.mind)) break;
|
|
1983
|
+
store.deleteMind(req.mind);
|
|
1984
|
+
break;
|
|
1985
|
+
}
|
|
1986
|
+
}
|
|
1987
|
+
} catch (error) {
|
|
1988
|
+
console.error(`[project-memory] Mind lifecycle '${req.action}' failed for '${req.mind}':`, error);
|
|
1989
|
+
}
|
|
1990
|
+
}
|
|
1991
|
+
|
|
1992
|
+
updateStatus(ctx);
|
|
1993
|
+
}
|
|
1994
|
+
|
|
1907
1995
|
// --- Tools ---
|
|
1908
1996
|
|
|
1909
1997
|
pi.registerTool({
|
|
@@ -2908,6 +2996,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
2908
2996
|
|
|
2909
2997
|
const theme = ctx.ui.theme;
|
|
2910
2998
|
const mind = activeMind();
|
|
2999
|
+
|
|
3000
|
+
// Publish active mind for dashboard directive indicator
|
|
3001
|
+
sharedState.activeMind = mind === "default" ? null : mind;
|
|
2911
3002
|
const count = store.countActiveFacts(mind);
|
|
2912
3003
|
|
|
2913
3004
|
// Label + fact count as a single unit: "Memory: 2 facts" or "Memory(mind): 2 facts"
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "omegon",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "Omegon — an opinionated distribution of pi (by Mario Zechner) with extensions for lifecycle management, memory, orchestration, and visualization",
|
|
5
5
|
"bin": {
|
|
6
6
|
"omegon": "bin/omegon.mjs",
|