kanbango 2.4.0 → 3.0.2
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/.ai/lessons.jsonl +4 -0
- package/.ai/retro/close.json +1 -0
- package/.ai/retro/last-run.json +1 -0
- package/AGENTS.md +11 -0
- package/CHANGELOG.md +43 -0
- package/LLM_AGENTS.md +110 -28
- package/README.md +6 -3
- package/agent-playbook.js +71 -0
- package/bin/kanban-cmd.js +1 -1
- package/bin/kanban.js +6 -6
- package/gui-registry.js +148 -0
- package/index.js +4 -0
- package/kanban.js +44 -133
- package/mcp-server.js +124 -85
- package/package.json +1 -1
- package/plan.js +1 -1
- package/tests/run.js +2 -0
package/kanban.js
CHANGED
|
@@ -3,10 +3,6 @@ const path = require('path');
|
|
|
3
3
|
|
|
4
4
|
const BACKLOG = path.join(process.cwd(), 'backlog');
|
|
5
5
|
const COLS = ['active', 'planned', 'icebox', 'done'];
|
|
6
|
-
const GUI_PORT_FILE = '.kanbango-gui.json';
|
|
7
|
-
const GUI_PORT_MIN = 5510;
|
|
8
|
-
const GUI_PORT_MAX = 5999;
|
|
9
|
-
const GUI_PORT_SPAN = GUI_PORT_MAX - GUI_PORT_MIN + 1;
|
|
10
6
|
const STATUS_MAP = {
|
|
11
7
|
active: 'in_progress',
|
|
12
8
|
planned: 'planned',
|
|
@@ -62,6 +58,16 @@ const VIEW_FIELDS = {
|
|
|
62
58
|
]
|
|
63
59
|
};
|
|
64
60
|
|
|
61
|
+
// Hard-required on create: title only (keeps GUI/CLI quick-add usable).
|
|
62
|
+
// Strongly recommended for agent/planned work — missing ones yield warnings, not errors.
|
|
63
|
+
const RECOMMENDED_CREATE_FIELDS = [
|
|
64
|
+
'description',
|
|
65
|
+
'specs',
|
|
66
|
+
'in_scope',
|
|
67
|
+
'out_of_scope',
|
|
68
|
+
'acceptance_criteria'
|
|
69
|
+
];
|
|
70
|
+
|
|
65
71
|
function createKanbanError(code, message, hint, details = {}, retryable = false, status = 400) {
|
|
66
72
|
const error = new Error(message);
|
|
67
73
|
error.code = code;
|
|
@@ -96,6 +102,36 @@ function normalizeStringArray(value) {
|
|
|
96
102
|
.filter(Boolean);
|
|
97
103
|
}
|
|
98
104
|
|
|
105
|
+
function isPresentCreateField(field, value) {
|
|
106
|
+
if (field === 'description' || field === 'specs' || field === 'notes') {
|
|
107
|
+
return Boolean(normalizeString(value));
|
|
108
|
+
}
|
|
109
|
+
if (
|
|
110
|
+
field === 'in_scope' ||
|
|
111
|
+
field === 'out_of_scope' ||
|
|
112
|
+
field === 'acceptance_criteria' ||
|
|
113
|
+
field === 'test_cases' ||
|
|
114
|
+
field === 'subtasks'
|
|
115
|
+
) {
|
|
116
|
+
if (field === 'subtasks') return normalizeSubtasks(value).length > 0;
|
|
117
|
+
return normalizeStringArray(value).length > 0;
|
|
118
|
+
}
|
|
119
|
+
return value !== undefined && value !== null && value !== '';
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function missingRecommendedCreateFields(payload = {}) {
|
|
123
|
+
return RECOMMENDED_CREATE_FIELDS.filter((field) => !isPresentCreateField(field, payload[field]));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function createFieldWarnings(payload = {}) {
|
|
127
|
+
const missing = missingRecommendedCreateFields(payload);
|
|
128
|
+
if (missing.length === 0) return [];
|
|
129
|
+
return [
|
|
130
|
+
`Strongly recommended fields missing: ${missing.join(', ')}. ` +
|
|
131
|
+
'Fill them on create (or via update) so scope and done-criteria are explicit.'
|
|
132
|
+
];
|
|
133
|
+
}
|
|
134
|
+
|
|
99
135
|
function normalizeSubtasks(value) {
|
|
100
136
|
if (!Array.isArray(value)) return [];
|
|
101
137
|
return value.map((subtask, idx) => ({
|
|
@@ -343,7 +379,7 @@ async function findFile(epicId) {
|
|
|
343
379
|
const candidates = files
|
|
344
380
|
.filter((file) => (file.endsWith('.json') || file.endsWith('.md'))
|
|
345
381
|
&& path.basename(file, path.extname(file)) === epicId)
|
|
346
|
-
.sort((left,
|
|
382
|
+
.sort((left, _right) => (left.endsWith('.json') ? -1 : 1));
|
|
347
383
|
if (candidates[0]) {
|
|
348
384
|
return path.join(colDir, candidates[0]);
|
|
349
385
|
}
|
|
@@ -524,11 +560,6 @@ async function doCreate(title, column = 'planned', epicGroup = '—', extra = {}
|
|
|
524
560
|
validateColumn(column, 'col');
|
|
525
561
|
|
|
526
562
|
const nextId = await nextTaskNumber();
|
|
527
|
-
const slug = title
|
|
528
|
-
.toLowerCase()
|
|
529
|
-
.replace(/[^a-z0-9]+/g, '-')
|
|
530
|
-
.replace(/^-+|-+$/g, '')
|
|
531
|
-
.substring(0, 25);
|
|
532
563
|
const task = normalizeTask({
|
|
533
564
|
id: String(nextId).padStart(3, '0'),
|
|
534
565
|
title,
|
|
@@ -729,118 +760,6 @@ async function doUpdate(epicId, newTitle, newTasks) {
|
|
|
729
760
|
}
|
|
730
761
|
}
|
|
731
762
|
|
|
732
|
-
function guiPortFilePath() {
|
|
733
|
-
return path.join(BACKLOG, GUI_PORT_FILE);
|
|
734
|
-
}
|
|
735
|
-
|
|
736
|
-
function hashCwdToPort(cwd = process.cwd()) {
|
|
737
|
-
let hash = 0;
|
|
738
|
-
const input = String(cwd);
|
|
739
|
-
for (let i = 0; i < input.length; i++) {
|
|
740
|
-
hash = ((hash << 5) - hash + input.charCodeAt(i)) | 0;
|
|
741
|
-
}
|
|
742
|
-
return GUI_PORT_MIN + (Math.abs(hash) % GUI_PORT_SPAN);
|
|
743
|
-
}
|
|
744
|
-
|
|
745
|
-
function normalizeGuiPort(value) {
|
|
746
|
-
const parsed = Number.parseInt(value, 10);
|
|
747
|
-
if (!Number.isFinite(parsed) || parsed < 1 || parsed > 65535) {
|
|
748
|
-
return null;
|
|
749
|
-
}
|
|
750
|
-
return parsed;
|
|
751
|
-
}
|
|
752
|
-
|
|
753
|
-
function resolvePreferredGuiPort(explicitPort) {
|
|
754
|
-
if (explicitPort !== undefined && explicitPort !== null && explicitPort !== '') {
|
|
755
|
-
const fromArg = normalizeGuiPort(explicitPort);
|
|
756
|
-
if (fromArg) return fromArg;
|
|
757
|
-
}
|
|
758
|
-
|
|
759
|
-
const fromEnv = normalizeGuiPort(process.env.KANBANGO_GUI_PORT);
|
|
760
|
-
if (fromEnv) return fromEnv;
|
|
761
|
-
|
|
762
|
-
return hashCwdToPort(process.cwd());
|
|
763
|
-
}
|
|
764
|
-
|
|
765
|
-
function isPidAlive(pid) {
|
|
766
|
-
const n = Number.parseInt(pid, 10);
|
|
767
|
-
if (!Number.isFinite(n) || n <= 0) return false;
|
|
768
|
-
try {
|
|
769
|
-
process.kill(n, 0);
|
|
770
|
-
return true;
|
|
771
|
-
} catch {
|
|
772
|
-
return false;
|
|
773
|
-
}
|
|
774
|
-
}
|
|
775
|
-
|
|
776
|
-
async function writeGuiPortFile({ port, pid = process.pid } = {}) {
|
|
777
|
-
const normalizedPort = normalizeGuiPort(port);
|
|
778
|
-
if (!normalizedPort) {
|
|
779
|
-
throw createKanbanError(
|
|
780
|
-
'VALIDATION_ERROR',
|
|
781
|
-
'Invalid GUI port',
|
|
782
|
-
'Use an integer between 1 and 65535',
|
|
783
|
-
{ port },
|
|
784
|
-
false,
|
|
785
|
-
400
|
|
786
|
-
);
|
|
787
|
-
}
|
|
788
|
-
|
|
789
|
-
await ensureBacklogDir();
|
|
790
|
-
const data = {
|
|
791
|
-
port: normalizedPort,
|
|
792
|
-
pid,
|
|
793
|
-
url: `http://localhost:${normalizedPort}`,
|
|
794
|
-
cwd: process.cwd(),
|
|
795
|
-
started_at: new Date().toISOString()
|
|
796
|
-
};
|
|
797
|
-
await fs.writeFile(guiPortFilePath(), JSON.stringify(data, null, 2), 'utf-8');
|
|
798
|
-
return data;
|
|
799
|
-
}
|
|
800
|
-
|
|
801
|
-
async function readGuiPortFile() {
|
|
802
|
-
try {
|
|
803
|
-
const raw = await fs.readFile(guiPortFilePath(), 'utf-8');
|
|
804
|
-
const data = JSON.parse(raw);
|
|
805
|
-
if (!data || !normalizeGuiPort(data.port)) return null;
|
|
806
|
-
return data;
|
|
807
|
-
} catch (error) {
|
|
808
|
-
if (error.code === 'ENOENT') return null;
|
|
809
|
-
return null;
|
|
810
|
-
}
|
|
811
|
-
}
|
|
812
|
-
|
|
813
|
-
async function clearGuiPortFile({ pid, force = false } = {}) {
|
|
814
|
-
const info = await readGuiPortFile();
|
|
815
|
-
if (!info) return false;
|
|
816
|
-
if (!force && pid !== undefined && info.pid !== pid) return false;
|
|
817
|
-
if (!force && pid === undefined && info.pid !== process.pid) return false;
|
|
818
|
-
|
|
819
|
-
try {
|
|
820
|
-
await fs.unlink(guiPortFilePath());
|
|
821
|
-
return true;
|
|
822
|
-
} catch (error) {
|
|
823
|
-
if (error.code === 'ENOENT') return false;
|
|
824
|
-
throw error;
|
|
825
|
-
}
|
|
826
|
-
}
|
|
827
|
-
|
|
828
|
-
async function discoverRunningGui() {
|
|
829
|
-
const info = await readGuiPortFile();
|
|
830
|
-
if (!info || !isPidAlive(info.pid)) {
|
|
831
|
-
if (info) await clearGuiPortFile({ force: true });
|
|
832
|
-
return null;
|
|
833
|
-
}
|
|
834
|
-
return {
|
|
835
|
-
status: 'running',
|
|
836
|
-
port: info.port,
|
|
837
|
-
pid: info.pid,
|
|
838
|
-
url: info.url || `http://localhost:${info.port}`,
|
|
839
|
-
cwd: info.cwd,
|
|
840
|
-
started_at: info.started_at
|
|
841
|
-
};
|
|
842
|
-
}
|
|
843
|
-
|
|
844
763
|
module.exports = {
|
|
845
764
|
ensureBacklogDir,
|
|
846
765
|
parseEpic,
|
|
@@ -855,20 +774,12 @@ module.exports = {
|
|
|
855
774
|
doUpdate,
|
|
856
775
|
doCreate,
|
|
857
776
|
createKanbanError,
|
|
777
|
+
createFieldWarnings,
|
|
778
|
+
missingRecommendedCreateFields,
|
|
858
779
|
getProgress,
|
|
859
780
|
resolveTaskId,
|
|
860
|
-
hashCwdToPort,
|
|
861
|
-
normalizeGuiPort,
|
|
862
|
-
resolvePreferredGuiPort,
|
|
863
|
-
isPidAlive,
|
|
864
|
-
writeGuiPortFile,
|
|
865
|
-
readGuiPortFile,
|
|
866
|
-
clearGuiPortFile,
|
|
867
|
-
discoverRunningGui,
|
|
868
|
-
guiPortFilePath,
|
|
869
781
|
COLS,
|
|
870
782
|
STATUS_MAP,
|
|
871
783
|
VIEW_FIELDS,
|
|
872
|
-
|
|
873
|
-
GUI_PORT_MAX
|
|
784
|
+
RECOMMENDED_CREATE_FIELDS
|
|
874
785
|
};
|