@goodandready/dsh-goal 0.2.30 → 0.2.31

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/lib/client.js CHANGED
@@ -1808,9 +1808,129 @@ window.__ModuleLoader__.load({
1808
1808
  background: var(--dsw-alias-bg-layer-2);
1809
1809
  font-family: monospace;
1810
1810
  }
1811
-
1811
+ .dsh-goal-tabs {
1812
+ display: flex;
1813
+ gap: 8px;
1814
+ border-bottom: 1px solid var(--dsw-alias-border-l1);
1815
+ margin: 10px 0 14px 0;
1816
+ padding-bottom: 6px;
1817
+ }
1818
+ .dsh-goal-tab-btn {
1819
+ background: transparent;
1820
+ border: none;
1821
+ font-size: 13px;
1822
+ font-weight: 500;
1823
+ color: var(--dsw-alias-label-secondary);
1824
+ cursor: pointer;
1825
+ padding: 6px 12px;
1826
+ border-radius: 6px;
1827
+ display: inline-flex;
1828
+ align-items: center;
1829
+ gap: 6px;
1830
+ transition: all 0.15s ease;
1831
+ }
1832
+ .dsh-goal-tab-btn:hover {
1833
+ color: var(--dsw-alias-label-primary);
1834
+ background: var(--dsw-alias-bg-layer-2);
1835
+ }
1836
+ .dsh-goal-tab-btn.active {
1837
+ color: var(--dsw-alias-brand-primary);
1838
+ background: var(--dsw-alias-bg-layer-2);
1839
+ font-weight: 600;
1840
+ }
1841
+ .dsh-goal-diff-box {
1842
+ display: flex;
1843
+ flex-direction: column;
1844
+ gap: 10px;
1845
+ }
1846
+ .dsh-goal-diff-summary {
1847
+ display: flex;
1848
+ align-items: center;
1849
+ justify-content: space-between;
1850
+ background: var(--dsw-alias-bg-sunken);
1851
+ border: 1px solid var(--dsw-alias-border-l1);
1852
+ border-radius: 6px;
1853
+ padding: 8px 12px;
1854
+ font-size: 12px;
1855
+ }
1856
+ .dsh-goal-diff-stat-pill {
1857
+ display: inline-flex;
1858
+ align-items: center;
1859
+ gap: 8px;
1860
+ font-weight: 600;
1861
+ }
1862
+ .dsh-goal-diff-ins {
1863
+ color: var(--dsw-alias-state-success-primary);
1864
+ }
1865
+ .dsh-goal-diff-del {
1866
+ color: var(--dsw-alias-state-danger-primary);
1867
+ }
1868
+ .dsh-goal-diff-files-list {
1869
+ display: flex;
1870
+ flex-wrap: wrap;
1871
+ gap: 6px;
1872
+ max-height: 100px;
1873
+ overflow-y: auto;
1874
+ }
1875
+ .dsh-goal-diff-file-chip {
1876
+ display: inline-flex;
1877
+ align-items: center;
1878
+ gap: 6px;
1879
+ background: var(--dsw-alias-bg-layer-2);
1880
+ border: 1px solid var(--dsw-alias-border-l1);
1881
+ border-radius: 4px;
1882
+ padding: 3px 8px;
1883
+ font-size: 11px;
1884
+ font-family: monospace;
1885
+ cursor: pointer;
1886
+ color: var(--dsw-alias-label-secondary);
1887
+ }
1888
+ .dsh-goal-diff-file-chip:hover,
1889
+ .dsh-goal-diff-file-chip.active {
1890
+ border-color: var(--dsw-alias-brand-primary);
1891
+ color: var(--dsw-alias-label-primary);
1892
+ }
1893
+ .dsh-goal-diff-viewer {
1894
+ background: var(--dsw-alias-bg-sunken);
1895
+ border: 1px solid var(--dsw-alias-border-l1);
1896
+ border-radius: 6px;
1897
+ padding: 10px;
1898
+ font-family: monospace;
1899
+ font-size: 12px;
1900
+ line-height: 1.45;
1901
+ max-height: 380px;
1902
+ overflow: auto;
1903
+ white-space: pre-wrap;
1904
+ word-break: break-all;
1905
+ }
1906
+ .dsh-goal-diff-line {
1907
+ display: block;
1908
+ padding: 1px 4px;
1909
+ border-radius: 2px;
1910
+ }
1911
+ .dsh-goal-diff-line-add {
1912
+ background: var(--dsw-alias-state-success-bg, var(--dsw-alias-bg-hover));
1913
+ color: var(--dsw-alias-state-success-primary);
1914
+ }
1915
+ .dsh-goal-diff-line-del {
1916
+ background: var(--dsw-alias-state-error-bg, var(--dsw-alias-bg-hover));
1917
+ color: var(--dsw-alias-state-danger-primary);
1918
+ }
1919
+ .dsh-goal-diff-line-hunk {
1920
+ background: var(--dsw-alias-bg-layer-2);
1921
+ color: var(--dsw-alias-state-info-primary);
1922
+ }
1923
+ .dsh-goal-diff-line-header {
1924
+ color: var(--dsw-alias-label-tertiary);
1925
+ font-weight: 600;
1926
+ }
1927
+ .dsh-goal-diff-empty {
1928
+ padding: 24px;
1929
+ text-align: center;
1930
+ color: var(--dsw-alias-label-secondary);
1931
+ font-size: 13px;
1932
+ }
1812
1933
  `;
1813
-
1814
1934
  function injectStylesOnce() {
1815
1935
 
1816
1936
  if (typeof document === 'undefined') return;
@@ -2103,6 +2223,146 @@ window.__ModuleLoader__.load({
2103
2223
  const [interveneText, setInterveneText] = useState('');
2104
2224
 
2105
2225
  const [interveneSent, setInterveneSent] = useState(false);
2226
+ const [activeTab, setActiveTab] = useState("overview");
2227
+ const [diffData, setDiffData] = useState(null);
2228
+ const [diffLoading, setDiffLoading] = useState(false);
2229
+ const [diffError, setDiffError] = useState(null);
2230
+ const [selectedFile, setSelectedFile] = useState(null);
2231
+
2232
+ const fetchDiff = useCallback(() => {
2233
+ setDiffLoading(true);
2234
+ setDiffError(null);
2235
+ const sid = state.sessionId || "default";
2236
+ const url = "/dsh-goal/diff?sessionId=" + encodeURIComponent(sid);
2237
+ if (typeof fetch === "function") {
2238
+ fetch(url)
2239
+ .then((res) => {
2240
+ if (!res.ok) throw new Error("HTTP " + res.status);
2241
+ return res.json();
2242
+ })
2243
+ .then((data) => {
2244
+ if (data && data.ok) {
2245
+ setDiffData(data);
2246
+ } else {
2247
+ setDiffError((data && data.error) || "Failed to load diff");
2248
+ }
2249
+ })
2250
+ .catch((err) => {
2251
+ setDiffError(err.message || "Network error");
2252
+ })
2253
+ .finally(() => {
2254
+ setDiffLoading(false);
2255
+ });
2256
+ } else {
2257
+ setDiffLoading(false);
2258
+ }
2259
+ }, [state.sessionId]);
2260
+
2261
+ useEffect(() => {
2262
+ if (activeTab === "diff") {
2263
+ fetchDiff();
2264
+ }
2265
+ }, [activeTab, fetchDiff]);
2266
+
2267
+ const renderDiffBox = () => {
2268
+ return React.createElement(
2269
+ "div",
2270
+ { className: "dsh-goal-diff-box" },
2271
+ React.createElement(
2272
+ "div",
2273
+ { className: "dsh-goal-diff-summary" },
2274
+ React.createElement(
2275
+ "div",
2276
+ { style: { display: "flex", alignItems: "center", gap: 8 } },
2277
+ React.createElement("span", null, (t("diffBaseCommit") || "Base:") + " ", React.createElement("code", null, (diffData && diffData.base ? diffData.base : (state.gitStartCommit || "main")).slice(0, 7))),
2278
+ React.createElement("span", null, "→"),
2279
+ React.createElement("span", null, (t("diffTargetBranch") || "Target:") + " ", React.createElement("code", null, (diffData && diffData.target) || state.gitBranch || state.branchName || "HEAD"))
2280
+ ),
2281
+ React.createElement(
2282
+ "div",
2283
+ { style: { display: "flex", alignItems: "center", gap: 8 } },
2284
+ diffData && diffData.stats ? React.createElement(
2285
+ "span",
2286
+ { className: "dsh-goal-diff-stat-pill" },
2287
+ React.createElement("span", { className: "dsh-goal-diff-ins" }, "+" + diffData.stats.insertions),
2288
+ React.createElement("span", { className: "dsh-goal-diff-del" }, "-" + diffData.stats.deletions)
2289
+ ) : null,
2290
+ React.createElement(
2291
+ "button",
2292
+ {
2293
+ type: "button",
2294
+ className: "dsh-goal-btn icon-only",
2295
+ style: { padding: "2px 8px", fontSize: 11 },
2296
+ onClick: fetchDiff,
2297
+ title: t("diffRefresh") || "Refresh Diff",
2298
+ },
2299
+ "🔄"
2300
+ )
2301
+ )
2302
+ ),
2303
+ diffData && diffData.files && diffData.files.length > 0 ? React.createElement(
2304
+ "div",
2305
+ { className: "dsh-goal-diff-files-list" },
2306
+ React.createElement(
2307
+ "span",
2308
+ {
2309
+ className: `dsh-goal-diff-file-chip${selectedFile === null ? " active" : ""}`,
2310
+ onClick: () => setSelectedFile(null),
2311
+ },
2312
+ (t("allFiles") || "All Files") + ` (${diffData.files.length})`
2313
+ ),
2314
+ diffData.files.map((f) => React.createElement(
2315
+ "span",
2316
+ {
2317
+ key: f.path,
2318
+ className: `dsh-goal-diff-file-chip${selectedFile === f.path ? " active" : ""}`,
2319
+ onClick: () => setSelectedFile(selectedFile === f.path ? null : f.path),
2320
+ title: `${f.path} (+${f.insertions} / -${f.deletions})`,
2321
+ },
2322
+ `${f.path.split("/").pop()} `,
2323
+ React.createElement("span", { className: "dsh-goal-diff-ins" }, "+" + f.insertions),
2324
+ "/",
2325
+ React.createElement("span", { className: "dsh-goal-diff-del" }, "-" + f.deletions)
2326
+ ))
2327
+ ) : null,
2328
+ diffLoading ? React.createElement("div", { className: "dsh-goal-diff-empty" }, t("diffLoading") || "Loading diff...")
2329
+ : diffError ? React.createElement("div", { className: "dsh-goal-diff-empty dsh-goal-gate-error" }, "Error: " + diffError)
2330
+ : !diffData || !diffData.diff || diffData.diff.trim().length === 0 ? React.createElement("div", { className: "dsh-goal-diff-empty" }, t("diffNoChanges") || "No git changes detected relative to base commit.")
2331
+ : React.createElement(
2332
+ "div",
2333
+ { className: "dsh-goal-diff-viewer" },
2334
+ (() => {
2335
+ const allLines = diffData.diff.split("\n");
2336
+ let linesToShow = allLines;
2337
+ if (selectedFile) {
2338
+ const fileChunks = [];
2339
+ let collecting = false;
2340
+ for (const l of allLines) {
2341
+ if (l.startsWith("diff --git ")) {
2342
+ const bPart = l.split(" b/")[1] || "";
2343
+ collecting = bPart === selectedFile;
2344
+ }
2345
+ if (collecting) fileChunks.push(l);
2346
+ }
2347
+ if (fileChunks.length > 0) linesToShow = fileChunks;
2348
+ }
2349
+ return linesToShow.map((line, idx) => {
2350
+ let lineClass = "dsh-goal-diff-line";
2351
+ if (line.startsWith("+") && !line.startsWith("+++")) {
2352
+ lineClass += " dsh-goal-diff-line-add";
2353
+ } else if (line.startsWith("-") && !line.startsWith("---")) {
2354
+ lineClass += " dsh-goal-diff-line-del";
2355
+ } else if (line.startsWith("@@")) {
2356
+ lineClass += " dsh-goal-diff-line-hunk";
2357
+ } else if (line.startsWith("diff --git") || line.startsWith("index ") || line.startsWith("---") || line.startsWith("+++")) {
2358
+ lineClass += " dsh-goal-diff-line-header";
2359
+ }
2360
+ return React.createElement("span", { key: idx, className: lineClass }, line || " ");
2361
+ });
2362
+ })()
2363
+ )
2364
+ );
2365
+ };
2106
2366
 
2107
2367
  const totalTokens = state.tokensUsage?.totalTokens || 0;
2108
2368
 
@@ -2222,7 +2482,35 @@ window.__ModuleLoader__.load({
2222
2482
 
2223
2483
  ),
2224
2484
 
2225
- isPlanMode && !isCompleted
2485
+ React.createElement(
2486
+ 'div',
2487
+ { className: 'dsh-goal-tabs' },
2488
+ React.createElement(
2489
+ 'button',
2490
+ {
2491
+ type: 'button',
2492
+ className: `dsh-goal-tab-btn${activeTab === "overview" ? " active" : ""}`,
2493
+ onClick: () => setActiveTab("overview"),
2494
+ },
2495
+ "📋 " + (t("tabOverview") || "Plan & Overview")
2496
+ ),
2497
+ React.createElement(
2498
+ 'button',
2499
+ {
2500
+ type: 'button',
2501
+ className: `dsh-goal-tab-btn${activeTab === "diff" ? " active" : ""}`,
2502
+ onClick: () => setActiveTab("diff"),
2503
+ },
2504
+ "🔍 " + (t("tabDiff") || "Changes / Diff") + (diffData && diffData.stats && diffData.stats.filesChanged ? ` (${diffData.stats.filesChanged})` : "")
2505
+ )
2506
+ ),
2507
+
2508
+ activeTab === 'diff'
2509
+ ? renderDiffBox()
2510
+ : React.createElement(
2511
+ React.Fragment,
2512
+ null,
2513
+ isPlanMode && !isCompleted
2226
2514
  ? React.createElement(
2227
2515
  'div',
2228
2516
  { className: 'dsh-goal-plan-banner' },
@@ -2716,7 +3004,8 @@ window.__ModuleLoader__.load({
2716
3004
  )
2717
3005
  )
2718
3006
  )
2719
- : null,
3007
+ : null
3008
+ ),
2720
3009
 
2721
3010
  ),
2722
3011
 
@@ -5533,6 +5822,17 @@ window.__ModuleLoader__.load({
5533
5822
  planningPhase: 'Planning Phase (Read-Only)',
5534
5823
  planPhaseNotice: 'Agent is formulating the plan. File modifications are guarded until operator approval.',
5535
5824
  launchModeLabel: 'Launch Mode:',
5825
+ tabOverview: 'Plan & Overview',
5826
+ tabDiff: 'Changes / Diff',
5827
+ diffLoading: 'Loading diff...',
5828
+ diffNoChanges: 'No git changes detected relative to base commit.',
5829
+ diffFilesChanged: 'Files changed:',
5830
+ diffInsertions: 'insertions',
5831
+ diffDeletions: 'deletions',
5832
+ diffBaseCommit: 'Base:',
5833
+ diffTargetBranch: 'Target:',
5834
+ diffRefresh: 'Refresh Diff',
5835
+ allFiles: 'All Files',
5536
5836
  planModeDesc: 'Plan first, review milestones, then execute',
5537
5837
  actModeDesc: 'Direct execution right away',
5538
5838
 
@@ -5684,6 +5984,17 @@ window.__ModuleLoader__.load({
5684
5984
  planningPhase: '规划阶段(只读)',
5685
5985
  planPhaseNotice: '智能体正在制定方案与里程碑。在操作者批准前,禁止修改文件。',
5686
5986
  launchModeLabel: '启动模式:',
5987
+ tabOverview: '方案与概览',
5988
+ tabDiff: '代码变更 / Diff',
5989
+ diffLoading: '正在加载差异...',
5990
+ diffNoChanges: '未检测到相对于基线提交的代码变更。',
5991
+ diffFilesChanged: '变更文件数:',
5992
+ diffInsertions: '行新增',
5993
+ diffDeletions: '行删除',
5994
+ diffBaseCommit: '基线:',
5995
+ diffTargetBranch: '目标:',
5996
+ diffRefresh: '刷新 Diff',
5997
+ allFiles: '所有文件',
5687
5998
  planModeDesc: '先制定里程碑方案,人工确认后再执行',
5688
5999
  actModeDesc: '立即开始编码与任务执行',
5689
6000
 
@@ -0,0 +1,147 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { getSafeCwd } from './goal-engine-constants.js';
3
+ import { isGitRepository, isValidGitRef } from './engine-git-branch.js';
4
+
5
+ /**
6
+ * Parse git diff --numstat output into structured file statistics
7
+ * @param {string} output
8
+ * @returns {Array<{ path: string, insertions: number, deletions: number, binary: boolean }>}
9
+ */
10
+ export function parseNumstat(output) {
11
+ if (!output || typeof output !== 'string') return [];
12
+ const lines = output.trim().split('\n');
13
+ const files = [];
14
+ for (const line of lines) {
15
+ if (!line.trim()) continue;
16
+ const parts = line.split('\t');
17
+ if (parts.length < 3) continue;
18
+ const [rawIns, rawDel, filePath] = parts;
19
+ const isBinary = rawIns === '-' || rawDel === '-';
20
+ const ins = isBinary ? 0 : parseInt(rawIns, 10) || 0;
21
+ const del = isBinary ? 0 : parseInt(rawDel, 10) || 0;
22
+ files.push({
23
+ path: filePath,
24
+ insertions: ins,
25
+ deletions: del,
26
+ binary: isBinary,
27
+ });
28
+ }
29
+ return files;
30
+ }
31
+
32
+ /**
33
+ * Get git diff between refs or current working tree
34
+ * @param {object} options
35
+ * @returns {object} diff result payload
36
+ */
37
+ export function getGitDiff({
38
+ cwd = process.cwd(),
39
+ base,
40
+ target,
41
+ file,
42
+ allowedRoots = [],
43
+ maxBytes = 256 * 1024,
44
+ } = {}) {
45
+ const safeCwd = getSafeCwd(cwd, allowedRoots);
46
+ if (!safeCwd || !isGitRepository(safeCwd, allowedRoots)) {
47
+ return { ok: false, error: 'Not a git repository or invalid path' };
48
+ }
49
+
50
+ // Ref validation: disallow flag injection and special chars
51
+ if (base && !isValidGitRef(base)) {
52
+ return { ok: false, error: 'Invalid base git ref' };
53
+ }
54
+ if (target && !isValidGitRef(target)) {
55
+ return { ok: false, error: 'Invalid target git ref' };
56
+ }
57
+ if (file && (typeof file !== 'string' || file.startsWith('-') || /[\0\r\n]/.test(file))) {
58
+ return { ok: false, error: 'Invalid file parameter' };
59
+ }
60
+
61
+ try {
62
+ const rangeArgs = [];
63
+ if (base && target) {
64
+ rangeArgs.push(`${base}..${target}`);
65
+ } else if (base) {
66
+ rangeArgs.push(base);
67
+ } else if (target) {
68
+ rangeArgs.push(target);
69
+ }
70
+
71
+ // 1. Fetch numstat for file summary
72
+ const numstatArgs = ['diff', '--numstat', ...rangeArgs];
73
+ if (file) {
74
+ numstatArgs.push('--', file);
75
+ }
76
+ const numstatOut = execFileSync('git', numstatArgs, {
77
+ cwd: safeCwd,
78
+ encoding: 'utf8',
79
+ stdio: ['ignore', 'pipe', 'pipe'],
80
+ timeout: 5000,
81
+ });
82
+
83
+ const files = parseNumstat(numstatOut);
84
+ const stats = {
85
+ filesChanged: files.length,
86
+ insertions: files.reduce((acc, f) => acc + f.insertions, 0),
87
+ deletions: files.reduce((acc, f) => acc + f.deletions, 0),
88
+ };
89
+
90
+ // 2. Fetch patch
91
+ const patchArgs = ['diff', '-p', '--no-color', ...rangeArgs];
92
+ if (file) {
93
+ patchArgs.push('--', file);
94
+ }
95
+ let patchOut = execFileSync('git', patchArgs, {
96
+ cwd: safeCwd,
97
+ encoding: 'utf8',
98
+ stdio: ['ignore', 'pipe', 'pipe'],
99
+ timeout: 5000,
100
+ maxBuffer: 2 * 1024 * 1024,
101
+ });
102
+
103
+ let truncated = false;
104
+ if (patchOut.length > maxBytes) {
105
+ patchOut = patchOut.slice(0, maxBytes);
106
+ truncated = true;
107
+ }
108
+
109
+ return {
110
+ ok: true,
111
+ cwd: safeCwd,
112
+ base: base || null,
113
+ target: target || 'HEAD',
114
+ stats,
115
+ files,
116
+ diff: patchOut,
117
+ truncated,
118
+ };
119
+ } catch (err) {
120
+ return { ok: false, error: err.message || 'git diff execution failed' };
121
+ }
122
+ }
123
+
124
+ /**
125
+ * GoalEngine helper to fetch diff for active or specified session
126
+ * @param {object} engine
127
+ * @param {string} sessionId
128
+ * @param {object} options
129
+ * @returns {object} diff result
130
+ */
131
+ export function getEngineGoalDiff(engine, sessionId = 'default', options = {}) {
132
+ const sid = sessionId || 'default';
133
+ const goal = engine.goals?.get(sid);
134
+ const allowedRoots = options.allowedRoots || engine.config?.allowedRoots || (goal?.cwd ? [goal.cwd, process.cwd()] : [process.cwd()]);
135
+ const cwd = options.cwd || goal?.cwd || engine.config?.cwd || process.cwd();
136
+ const base = options.base || goal?.gitStartCommit || null;
137
+ const target = options.target || goal?.gitBranch || goal?.branchName || 'HEAD';
138
+
139
+ return getGitDiff({
140
+ cwd,
141
+ base,
142
+ target,
143
+ file: options.file,
144
+ allowedRoots,
145
+ maxBytes: options.maxBytes,
146
+ });
147
+ }
@@ -0,0 +1,242 @@
1
+ import { MilestoneStatus } from './goal-engine-constants.js';
2
+
3
+ export const MILESTONE_TO_KANBAN_COLUMN = {
4
+ [MilestoneStatus.PENDING]: 'todo',
5
+ [MilestoneStatus.IN_PROGRESS]: 'in_progress',
6
+ [MilestoneStatus.COMPLETED]: 'done',
7
+ pending: 'todo',
8
+ in_progress: 'in_progress',
9
+ active: 'in_progress',
10
+ completed: 'done',
11
+ PENDING: 'todo',
12
+ ACTIVE: 'in_progress',
13
+ COMPLETED: 'done',
14
+ };
15
+
16
+ export const KANBAN_COLUMN_TO_MILESTONE_STATUS = {
17
+ backlog: MilestoneStatus.PENDING,
18
+ todo: MilestoneStatus.PENDING,
19
+ in_progress: MilestoneStatus.IN_PROGRESS,
20
+ doing: MilestoneStatus.IN_PROGRESS,
21
+ active: MilestoneStatus.IN_PROGRESS,
22
+ review: MilestoneStatus.COMPLETED,
23
+ done: MilestoneStatus.COMPLETED,
24
+ completed: MilestoneStatus.COMPLETED,
25
+ };
26
+
27
+ /**
28
+ * Format externalRef string for a goal milestone
29
+ * @param {string} goalId
30
+ * @param {string} milestoneId
31
+ * @returns {string}
32
+ */
33
+ export function formatMilestoneExternalRef(goalId, milestoneId) {
34
+ return `dsh-goal:${goalId || 'default'}:${milestoneId}`;
35
+ }
36
+
37
+ /**
38
+ * Parse milestone externalRef
39
+ * @param {string} externalRef
40
+ * @returns {{ goalId: string, milestoneId: string } | null}
41
+ */
42
+ export function parseMilestoneExternalRef(externalRef) {
43
+ if (typeof externalRef !== 'string' || !externalRef.startsWith('dsh-goal:')) {
44
+ return null;
45
+ }
46
+ const parts = externalRef.split(':');
47
+ if (parts.length < 3) return null;
48
+ return {
49
+ goalId: parts[1],
50
+ milestoneId: parts.slice(2).join(':'),
51
+ };
52
+ }
53
+
54
+ /**
55
+ * Resolve active kanban service or store from context/options
56
+ * @param {object} options
57
+ * @returns {{ service: object | null, store: object | null }}
58
+ */
59
+ export function resolveKanbanServices(options = {}) {
60
+ const ctx = options.ctx;
61
+ let service = options.kanbanService || null;
62
+ let store = options.store || null;
63
+
64
+ if (!service && ctx) {
65
+ service = ctx.dshKanban || (typeof ctx.get === 'function' ? ctx.get('dshKanban') : null);
66
+ }
67
+
68
+ if (!store) {
69
+ if (service && typeof service.getStore === 'function') {
70
+ store = service.getStore();
71
+ } else if (ctx?.kanbanStore) {
72
+ store = ctx.kanbanStore;
73
+ }
74
+ }
75
+
76
+ return { service, store };
77
+ }
78
+
79
+ /**
80
+ * Synchronize all milestones of a goal with dsh-kanban
81
+ * Safe degradation if kanban is not available
82
+ * @param {object} engine
83
+ * @param {string} sessionId
84
+ * @param {object} options
85
+ * @returns {Promise<object>} sync summary
86
+ */
87
+ export async function syncGoalToKanban(engine, sessionId = 'default', options = {}) {
88
+ const sid = sessionId || 'default';
89
+ const goal = engine.goals?.get(sid);
90
+ if (!goal) {
91
+ return { ok: false, error: 'Goal not found' };
92
+ }
93
+ if (!goal.milestones || goal.milestones.length === 0) {
94
+ return { ok: true, synced: 0, reason: 'No milestones in goal' };
95
+ }
96
+
97
+ const { service, store } = resolveKanbanServices({
98
+ kanbanService: options.kanbanService || engine.kanbanService,
99
+ store: options.store || engine.kanbanStore,
100
+ ctx: options.ctx || engine.ctx,
101
+ ...options,
102
+ });
103
+
104
+ if (!service && !store) {
105
+ return { ok: false, skipped: true, reason: 'kanban-unavailable' };
106
+ }
107
+
108
+ const results = [];
109
+ for (const ms of goal.milestones) {
110
+ const externalRef = formatMilestoneExternalRef(goal.id, ms.id);
111
+ const col = MILESTONE_TO_KANBAN_COLUMN[ms.status] || 'todo';
112
+ const title = `[Goal] ${ms.title}`;
113
+ const body = ms.notes || `Milestone ${ms.id} for goal: ${goal.title}`;
114
+
115
+ try {
116
+ if (service && typeof service.createTask === 'function') {
117
+ const res = await service.createTask({
118
+ title,
119
+ body,
120
+ column: col,
121
+ externalRef,
122
+ labels: ['dsh-goal', String(goal.id)],
123
+ });
124
+ results.push({ milestoneId: ms.id, taskId: res.taskId || res.task?.id, ok: res.ok, alreadyExists: res.alreadyExists });
125
+ } else if (store && typeof store.createTask === 'function') {
126
+ const existing = typeof store.findTaskByExternalRef === 'function' ? store.findTaskByExternalRef(externalRef) : null;
127
+ if (!existing) {
128
+ const task = store.createTask({
129
+ title,
130
+ body,
131
+ column: col,
132
+ externalRef,
133
+ labels: ['dsh-goal', String(goal.id)],
134
+ });
135
+ results.push({ milestoneId: ms.id, taskId: task.id, ok: true, alreadyExists: false });
136
+ } else {
137
+ results.push({ milestoneId: ms.id, taskId: existing.id, ok: true, alreadyExists: true });
138
+ }
139
+ }
140
+ } catch (err) {
141
+ engine.logger?.warn?.(`[dsh-goal] Kanban milestone sync failed for ${ms.id}:`, err?.message || err);
142
+ results.push({ milestoneId: ms.id, ok: false, error: err?.message || String(err) });
143
+ }
144
+ }
145
+
146
+ return { ok: true, synced: results.filter((r) => r.ok).length, total: goal.milestones.length, results };
147
+ }
148
+
149
+ /**
150
+ * Update corresponding kanban card column when milestone status changes
151
+ * @param {object} milestone
152
+ * @param {object} options
153
+ * @returns {Promise<object>}
154
+ */
155
+ export async function syncMilestoneStatusToKanban(milestone, { goal, ctx, store, kanbanService } = {}) {
156
+ if (!milestone || !goal) return { ok: false, error: 'Invalid milestone or goal' };
157
+
158
+ const { store: activeStore } = resolveKanbanServices({
159
+ store,
160
+ kanbanService,
161
+ ctx,
162
+ });
163
+ if (!activeStore || typeof activeStore.findTaskByExternalRef !== 'function') {
164
+ return { ok: false, skipped: true, reason: 'kanban-store-unavailable' };
165
+ }
166
+
167
+ const externalRef = formatMilestoneExternalRef(goal.id, milestone.id);
168
+ const targetCol = MILESTONE_TO_KANBAN_COLUMN[milestone.status] || 'todo';
169
+
170
+ try {
171
+ const task = activeStore.findTaskByExternalRef(externalRef);
172
+ if (!task) {
173
+ return { ok: false, skipped: true, reason: 'kanban-task-not-found' };
174
+ }
175
+
176
+ if (task.column !== targetCol && typeof activeStore.moveTask === 'function') {
177
+ activeStore.moveTask(task.id, { column: targetCol });
178
+ return { ok: true, synced: true, taskId: task.id, column: targetCol };
179
+ }
180
+
181
+ return { ok: true, synced: true, taskId: task.id, column: task.column, unchanged: true };
182
+ } catch (err) {
183
+ return { ok: false, error: err?.message || String(err) };
184
+ }
185
+ }
186
+
187
+ /**
188
+ * Handle external card movement from dsh-kanban board
189
+ * @param {object} engine
190
+ * @param {object} payload
191
+ * @param {string} sessionId
192
+ * @returns {object}
193
+ */
194
+ export function handleKanbanCardMoved(engine, payload = {}, sessionId = 'default') {
195
+ const externalRef = payload.externalRef;
196
+ const column = payload.column;
197
+ const targetStatus = payload.status || (column ? KANBAN_COLUMN_TO_MILESTONE_STATUS[column] : null);
198
+
199
+ if (!targetStatus) {
200
+ return { ok: false, error: 'Invalid or missing target milestone status/column' };
201
+ }
202
+
203
+ let goalId = null;
204
+ let milestoneId = payload.milestoneId || null;
205
+
206
+ if (externalRef) {
207
+ const parsed = parseMilestoneExternalRef(externalRef);
208
+ if (parsed) {
209
+ goalId = parsed.goalId;
210
+ milestoneId = parsed.milestoneId;
211
+ }
212
+ }
213
+
214
+ const candidateSessions = sessionId && sessionId !== 'default'
215
+ ? [sessionId, 'default']
216
+ : ['default', ...engine.goals.keys()];
217
+
218
+ for (const sid of candidateSessions) {
219
+ const goal = engine.goals?.get(sid);
220
+ if (!goal || (goalId && String(goal.id) !== String(goalId))) continue;
221
+
222
+ const ms = goal.milestones?.find((m) => String(m.id) === String(milestoneId) || m.title === payload.title);
223
+ if (ms) {
224
+ const updateRes = engine.updateMilestone(
225
+ ms.id,
226
+ targetStatus,
227
+ `Synced from Kanban card move to column '${column || targetStatus}'`,
228
+ sid
229
+ );
230
+ return {
231
+ ok: true,
232
+ sessionId: sid,
233
+ milestoneId: ms.id,
234
+ prevStatus: ms.status,
235
+ newStatus: targetStatus,
236
+ updateResult: updateRes,
237
+ };
238
+ }
239
+ }
240
+
241
+ return { ok: false, error: 'Matching milestone not found for external reference' };
242
+ }
@@ -1,4 +1,6 @@
1
1
  import { verifyMilestoneAcceptance } from './engine-verification.js';
2
+ import { getEngineGoalDiff } from './engine-diff.js';
3
+ import { syncGoalToKanban, syncMilestoneStatusToKanban, handleKanbanCardMoved } from './engine-kanban-sync.js';
2
4
  import { askOperatorQuery, answerOperatorQuery } from './engine-operator-query.js';
3
5
  import { GoalMode, GoalState, MilestoneStatus, formatElapsed, formatETA, detectLanguage, sessionIdOf, calculateElapsedSeconds, calculateRemainingSeconds, buildSnapshot, getSafeCwd } from './goal-engine-constants.js';
4
6
  import { getGitCurrentCommit, exportReportMarkdown, exportReportGitHubPR, createMilestoneCheckpoint, rollbackToCheckpoint, saveGoalArtifact } from './engine-reports.js';
@@ -31,7 +33,7 @@ export class GoalEngine {
31
33
  enableLiveActivityFeed: options.enableLiveActivityFeed ?? true, enablePreplanning: options.enablePreplanning ?? true,
32
34
  autoBranchOnGoalStart: options.autoBranchOnGoalStart ?? true, enablePostGoalRetrospective: options.enablePostGoalRetrospective ?? true,
33
35
  autoScaleBudgetNearCompletion: options.autoScaleBudgetNearCompletion ?? false, enableTemplatesDrawer: options.enableTemplatesDrawer ?? true,
34
- enableMilestoneDependencies: options.enableMilestoneDependencies ?? true, soundScheme: options.soundScheme ?? 'default',
36
+ enableMilestoneDependencies: options.enableMilestoneDependencies ?? true, soundScheme: options.soundScheme ?? 'default', enableKanbanSync: options.enableKanbanSync ?? true, ctx: options.ctx || null, kanbanService: options.kanbanService || null, kanbanStore: options.kanbanStore || null,
35
37
  enableVoiceAnnouncements: options.enableVoiceAnnouncements ?? false, approvalGate: options.approvalGate || null,
36
38
  checkApproval: options.checkApproval || null, maxSessions: options.maxSessions ?? 100,
37
39
  });
@@ -180,7 +182,7 @@ export class GoalEngine {
180
182
  title: cleanTitle,
181
183
  description: options.description?.trim() || '',
182
184
  lang: options.lang || detectLanguage(cleanTitle),
183
- cwd: options.cwd ? getSafeCwd(options.cwd) : null,
185
+ cwd: options.cwd ? getSafeCwd(options.cwd, options.allowedRoots || this.allowedRoots || []) : null,
184
186
  mode: initialMode,
185
187
  state: initialState,
186
188
  startedAt: now,
@@ -193,7 +195,7 @@ export class GoalEngine {
193
195
  budgetWarningTriggered: false,
194
196
  budgetAutoScaled: false,
195
197
  gitStartCommit: gitCommit || null,
196
- gitBranch: null,
198
+ gitBranch: options.gitBranch || options.branchName || null,
197
199
  gitParentBranch: null,
198
200
  pendingNudge: null,
199
201
  nudges: [],
@@ -315,7 +317,8 @@ export class GoalEngine {
315
317
  }
316
318
 
317
319
  addMilestones(milestonesList, shouldEmit = true, sessionId = 'default') {
318
- const sid = sessionId || 'default';
320
+ const sid = typeof shouldEmit === 'string' ? shouldEmit : (sessionId || 'default');
321
+ const shouldEmitFlag = typeof shouldEmit === 'boolean' ? shouldEmit : true;
319
322
  const goal = this.goals.get(sid);
320
323
  if (!goal || !Array.isArray(milestonesList)) return [];
321
324
 
@@ -325,7 +328,7 @@ export class GoalEngine {
325
328
  }
326
329
  this.recordProgress(sid);
327
330
  recordActivity(goal, { type: 'milestone', tool: 'addMilestones', status: 'success', details: `Added ${newMilestones.length} milestones` });
328
- if (shouldEmit) this.emit(sid);
331
+ if (shouldEmitFlag) this.emit(sid);
329
332
  return newMilestones;
330
333
  }
331
334
 
@@ -359,6 +362,7 @@ export class GoalEngine {
359
362
 
360
363
  const prevStatus = res.prevStatus;
361
364
  recordActivity(goal, { type: 'milestone', tool: 'updateMilestone', status: 'success', details: `${id} -> ${status}` });
365
+ if (this.enableKanbanSync) syncMilestoneStatusToKanban(target, { goal, ctx: this.ctx, store: this.kanbanStore, kanbanService: this.kanbanService }).catch(() => {});
362
366
 
363
367
  if (target.status === MilestoneStatus.COMPLETED && prevStatus !== MilestoneStatus.COMPLETED) {
364
368
  if (this.autoCheckpointOnMilestone) {
@@ -579,6 +583,10 @@ export class GoalEngine {
579
583
  return res;
580
584
  }
581
585
 
586
+ getDiff(sessionId = 'default', options = {}) { return getEngineGoalDiff(this, sessionId, options); }
587
+ syncKanban(sessionId = 'default', options = {}) { return syncGoalToKanban(this, sessionId, options); }
588
+ handleKanbanCardMove(payload = {}, sessionId = 'default') { return handleKanbanCardMoved(this, payload, sessionId); }
589
+
582
590
  isPlanMode(sessionId = 'default') {
583
591
  const goal = this.goals.get(sessionId || 'default');
584
592
  return Boolean(goal && (goal.mode === GoalMode.PLAN || goal.state === GoalState.PLANNING));
package/lib/routes.js CHANGED
@@ -383,6 +383,28 @@ export function registerRoutes(ctx, {
383
383
  }));
384
384
  }
385
385
 
386
+ // GET /dsh-goal/diff
387
+ if (req.method === 'GET' && (pathname === '/dsh-goal/diff' || pathname === '/dsh-goal/diff/')) {
388
+ const rejection = getRequestRejection(req, { ctx });
389
+ if (rejection !== undefined) {
390
+ res.statusCode = rejection;
391
+ return res.end(JSON.stringify({ error: rejection === 401 ? 'Unauthorized: authentication required' : 'Forbidden: untrusted caller origin' }));
392
+ }
393
+ const sid = sessionIdOf(req, 'default');
394
+ const target = url.searchParams.get('target') || undefined;
395
+ const base = url.searchParams.get('base') || undefined;
396
+ const file = url.searchParams.get('file') || undefined;
397
+ const cwd = url.searchParams.get('cwd') || undefined;
398
+
399
+ if (engine) {
400
+ const diffResult = engine.getDiff(sid, { target, base, file, cwd });
401
+ res.statusCode = diffResult.ok ? 200 : 400;
402
+ return res.end(JSON.stringify(diffResult));
403
+ }
404
+ res.statusCode = 404;
405
+ return res.end(JSON.stringify({ ok: false, error: 'Goal engine unavailable' }));
406
+ }
407
+
386
408
  // POST /dsh-goal/action
387
409
  // CSRF & Same-Origin guard
388
410
  if (req.method === 'POST' && (pathname === '/dsh-goal/action' || pathname === '/dsh-goal/action/')) {
@@ -429,7 +451,7 @@ export function registerRoutes(ctx, {
429
451
  body += chunk;
430
452
  };
431
453
 
432
- const onEnd = () => {
454
+ const onEnd = async () => {
433
455
  if (limitExceeded) return;
434
456
  try {
435
457
  const data = JSON.parse(body || '{}');
@@ -829,6 +851,28 @@ export function registerRoutes(ctx, {
829
851
  res.statusCode = 200;
830
852
  return res.end(JSON.stringify({ ok: true, templates: getTemplatesCatalogue() }));
831
853
 
854
+ case 'kanban_move': {
855
+ if (engine) {
856
+ result = engine.handleKanbanCardMove(data, sid);
857
+ if (!result.ok) res.statusCode = 400;
858
+ } else {
859
+ res.statusCode = 404;
860
+ result = { ok: false, error: 'Goal engine unavailable' };
861
+ }
862
+ break;
863
+ }
864
+
865
+ case 'kanban_sync': {
866
+ if (engine) {
867
+ result = await engine.syncKanban(sid, { ctx });
868
+ if (!result.ok && !result.skipped) res.statusCode = 400;
869
+ } else {
870
+ res.statusCode = 404;
871
+ result = { ok: false, error: 'Goal engine unavailable' };
872
+ }
873
+ break;
874
+ }
875
+
832
876
  case 'instantiate_template': {
833
877
  const tplId = data.templateId || data.id;
834
878
  if (!tplId) {
@@ -870,6 +914,7 @@ export function registerRoutes(ctx, {
870
914
  '/dsh-goal/events': ['GET'],
871
915
  '/dsh-goal/templates': ['GET'],
872
916
  '/dsh-goal/state': ['GET'],
917
+ '/dsh-goal/diff': ['GET'],
873
918
  '/dsh-goal/action': ['POST'],
874
919
  };
875
920
  const cleanPath = pathname.replace(/\/+$/, '') || '/';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-goal",
3
- "version": "0.2.30",
3
+ "version": "0.2.31",
4
4
  "description": "Core Goal Addon (Token Budget, Milestones, Git Snapshots & Retrospective Reports) for DeepSeek Harness",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",