nikos 0.1.1 → 0.2.1
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/README.md +46 -9
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +49 -1
- package/dist/commands/approve.d.ts +16 -0
- package/dist/commands/approve.js +120 -0
- package/dist/commands/ci.d.ts +19 -0
- package/dist/commands/ci.js +174 -0
- package/dist/commands/clear.d.ts +14 -0
- package/dist/commands/decide.d.ts +11 -0
- package/dist/commands/decide.js +3 -3
- package/dist/commands/decisions.d.ts +14 -0
- package/dist/commands/decisions.js +2 -2
- package/dist/commands/doctor.d.ts +11 -0
- package/dist/commands/doctor.js +92 -0
- package/dist/commands/export.d.ts +48 -0
- package/dist/commands/export.js +164 -0
- package/dist/commands/init.d.ts +16 -0
- package/dist/commands/init.js +8 -12
- package/dist/commands/join.d.ts +17 -0
- package/dist/commands/join.js +91 -0
- package/dist/commands/map.d.ts +65 -0
- package/dist/commands/map.js +202 -25
- package/dist/commands/migrate.d.ts +10 -0
- package/dist/commands/migrate.js +105 -0
- package/dist/commands/next.d.ts +19 -0
- package/dist/commands/next.js +29 -5
- package/dist/commands/plan.d.ts +8 -0
- package/dist/commands/plan.js +9 -8
- package/dist/commands/release.d.ts +66 -0
- package/dist/commands/release.js +75 -6
- package/dist/commands/show.d.ts +3 -0
- package/dist/commands/show.js +26 -4
- package/dist/commands/start.d.ts +28 -0
- package/dist/commands/start.js +138 -0
- package/dist/commands/state.d.ts +27 -0
- package/dist/commands/state.js +40 -22
- package/dist/commands/step.d.ts +17 -0
- package/dist/commands/step.js +38 -2
- package/dist/commands/story.d.ts +21 -0
- package/dist/commands/story.js +7 -10
- package/dist/commands/suggest.d.ts +16 -0
- package/dist/commands/suggest.js +140 -0
- package/dist/commands/tracker.d.ts +60 -0
- package/dist/commands/tracker.js +185 -0
- package/dist/commands/validate.d.ts +15 -0
- package/dist/commands/validate.js +149 -14
- package/dist/commands/verify.d.ts +20 -0
- package/dist/commands/video.d.ts +33 -0
- package/dist/commands/video.js +4 -3
- package/dist/index.d.ts +20 -0
- package/dist/index.js +24 -0
- package/dist/lib/approvals.d.ts +29 -0
- package/dist/lib/approvals.js +28 -0
- package/dist/lib/bets.d.ts +21 -0
- package/dist/lib/bets.js +9 -2
- package/dist/lib/frontmatter.d.ts +5 -0
- package/dist/lib/project.d.ts +25 -0
- package/dist/lib/project.js +25 -7
- package/dist/lib/settings.d.ts +29 -0
- package/dist/lib/settings.js +110 -0
- package/dist/lib/state-files.d.ts +44 -0
- package/dist/lib/state-files.js +102 -0
- package/dist/lib/store.d.ts +44 -0
- package/dist/lib/store.js +97 -0
- package/dist/lint/constitution.d.ts +5 -0
- package/dist/lint/gherkin.d.ts +12 -0
- package/dist/lint/gherkin.js +2 -2
- package/dist/lint/portable.d.ts +10 -0
- package/dist/mcp/server.d.ts +4 -0
- package/dist/mcp/server.js +239 -0
- package/dist/planning/bets.d.ts +31 -0
- package/dist/planning/cli.d.ts +8 -0
- package/dist/planning/cli.js +17 -1
- package/dist/planning/delivery.d.ts +12 -0
- package/dist/planning/delivery.js +8 -5
- package/dist/planning/krs.d.ts +28 -0
- package/dist/planning/model.d.ts +55 -0
- package/dist/planning/model.js +10 -10
- package/dist/planning/rollout.d.ts +72 -0
- package/dist/planning/rollout.js +206 -0
- package/dist/planning/server.d.ts +113 -0
- package/dist/planning/server.js +9 -6
- package/dist/planning/ui.d.ts +7 -0
- package/dist/planning/ui.js +26 -1
- package/dist/planning/upstream.d.ts +73 -0
- package/dist/planning/upstream.js +78 -20
- package/dist/verify/detect.d.ts +3 -0
- package/docs/format.md +110 -1
- package/docs/gherkin.md +3 -0
- package/docs/install.md +62 -0
- package/docs/library.md +53 -0
- package/docs/planning.md +51 -5
- package/docs/settings.md +112 -0
- package/package.json +12 -2
- package/schemas/approvals.schema.json +27 -0
- package/schemas/planning/kr.schema.json +1 -1
- package/schemas/settings.schema.json +40 -0
- package/schemas/state.schema.json +6 -2
- package/schemas/storymap.schema.json +13 -0
- package/skills/nikos-mode/SKILL.md +22 -1
- package/skills/nikos-mode/playbooks/build-a-story.md +1 -1
- package/skills/nikos-mode/playbooks/company.md +16 -0
- package/skills/nikos-mode/playbooks/index.md +4 -0
- package/skills/nikos-mode/playbooks/map-existing.md +14 -0
- package/skills/nikos-mode/playbooks/start.md +12 -0
- package/skills/nikos-mode/playbooks/tracker.md +14 -0
- package/skills/nikos-mode/trackers/github.md +18 -0
- package/skills/nikos-mode/trackers/gitlab.md +17 -0
- package/skills/nikos-mode/trackers/jira.md +19 -0
- package/skills/nikos-mode/trackers/linear.md +17 -0
- package/skills/verify/SKILL.md +16 -0
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// Settings in nikos.settings.yaml, at a repo's root. A company sets them once; departments, groups
|
|
2
|
+
// and teams inherit them through planning/upstream.yaml (synced into .nikos/upstream/), and can
|
|
3
|
+
// change any value the levels above haven't locked. Each value remembers where it came from.
|
|
4
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
5
|
+
import { dirname, join, resolve } from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import Ajv2020Module from "ajv/dist/2020.js";
|
|
8
|
+
import { parse, parseDocument } from "yaml";
|
|
9
|
+
import { store } from "./store.js";
|
|
10
|
+
import { REPO_PATH } from "./bets.js";
|
|
11
|
+
const Ajv2020 = Ajv2020Module.default ?? Ajv2020Module;
|
|
12
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
13
|
+
const schemaDir = [join(here, "../../schemas"), join(here, "../schemas")].find((d) => existsSync(d));
|
|
14
|
+
const check = new Ajv2020({ allErrors: true, strict: false }).compile(JSON.parse(readFileSync(join(schemaDir, "settings.schema.json"), "utf8")));
|
|
15
|
+
export const SETTINGS_FILE = "nikos.settings.yaml";
|
|
16
|
+
/** Every setting nikOS knows, with its default. Keys are dotted paths into the file. */
|
|
17
|
+
export const DEFAULTS = {
|
|
18
|
+
"approval.policy": "optional",
|
|
19
|
+
"approval.routes": ["app", "chat", "terminal"],
|
|
20
|
+
"tracker.system": "none",
|
|
21
|
+
"tracker.site": "",
|
|
22
|
+
"tracker.project": "",
|
|
23
|
+
"tracker.statuses": {},
|
|
24
|
+
"tracker.approvals": false,
|
|
25
|
+
};
|
|
26
|
+
const flatten = (data) => {
|
|
27
|
+
const out = {};
|
|
28
|
+
for (const key of Object.keys(DEFAULTS)) {
|
|
29
|
+
const value = key.split(".").reduce((v, k) => (v == null ? undefined : v[k]), data);
|
|
30
|
+
if (value !== undefined)
|
|
31
|
+
out[key] = value;
|
|
32
|
+
}
|
|
33
|
+
return out;
|
|
34
|
+
};
|
|
35
|
+
/** A repo's own settings file, parsed and checked. */
|
|
36
|
+
export function readSettings(root) {
|
|
37
|
+
const path = join(root, SETTINGS_FILE);
|
|
38
|
+
if (!store().exists(path))
|
|
39
|
+
return { values: {}, locked: [] };
|
|
40
|
+
const data = parse(store().read(path)) ?? {};
|
|
41
|
+
if (!check(data)) {
|
|
42
|
+
const e = check.errors[0];
|
|
43
|
+
throw new Error(`${SETTINGS_FILE}: ${e.instancePath.replace(/^\//, "").replace(/\//g, ".") || "(root)"} ${e.message}`);
|
|
44
|
+
}
|
|
45
|
+
return { values: flatten(data), locked: data.locked ?? [] };
|
|
46
|
+
}
|
|
47
|
+
/** The repo paths this repo (or a synced copy) names as its upstreams, in order. */
|
|
48
|
+
function upstreamsOf(dir) {
|
|
49
|
+
const path = join(dir, "planning", "upstream.yaml");
|
|
50
|
+
if (!store().exists(path))
|
|
51
|
+
return [];
|
|
52
|
+
const list = parse(store().read(path));
|
|
53
|
+
return Array.isArray(list) ? list.map((e) => e?.repo).filter((r) => typeof r === "string" && REPO_PATH.test(r)) : [];
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* The settings that apply to a repo: defaults, then each level above it from the top down
|
|
57
|
+
* (through the synced copies in .nikos/upstream/), then the repo's own. A level can't change a
|
|
58
|
+
* value a level above it locked.
|
|
59
|
+
*/
|
|
60
|
+
export function resolveSettings(root) {
|
|
61
|
+
const cache = join(resolve(root), ".nikos", "upstream");
|
|
62
|
+
const out = Object.fromEntries(Object.entries(DEFAULTS).map(([k, v]) => [k, { value: v, from: "default" }]));
|
|
63
|
+
const visiting = new Set();
|
|
64
|
+
const apply = (dir, name) => {
|
|
65
|
+
if (visiting.has(dir))
|
|
66
|
+
return; // a loop of upstreams: each level counts once
|
|
67
|
+
visiting.add(dir);
|
|
68
|
+
for (const up of upstreamsOf(dir)) {
|
|
69
|
+
const copy = join(cache, ...up.split("/"));
|
|
70
|
+
if (store().exists(copy))
|
|
71
|
+
apply(copy, up);
|
|
72
|
+
}
|
|
73
|
+
const own = readSettings(dir);
|
|
74
|
+
for (const [key, value] of Object.entries(own.values)) {
|
|
75
|
+
if (out[key].lockedBy)
|
|
76
|
+
continue;
|
|
77
|
+
out[key] = { value, from: name };
|
|
78
|
+
}
|
|
79
|
+
for (const key of own.locked)
|
|
80
|
+
if (out[key] && !out[key].lockedBy)
|
|
81
|
+
out[key].lockedBy = name;
|
|
82
|
+
};
|
|
83
|
+
apply(resolve(root), "this repo");
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
export const settingValue = (root, key) => resolveSettings(root)[key].value;
|
|
87
|
+
/** Change a setting in this repo's own file, unless a level above locked it. */
|
|
88
|
+
export function setSetting(root, key, value) {
|
|
89
|
+
if (!(key in DEFAULTS))
|
|
90
|
+
throw new Error(`'${key}' isn't a setting. Settings: ${Object.keys(DEFAULTS).join(", ")}`);
|
|
91
|
+
const current = resolveSettings(root)[key];
|
|
92
|
+
if (current.lockedBy && current.lockedBy !== "this repo")
|
|
93
|
+
throw new Error(`${key} is locked by ${current.lockedBy}, so it can't be changed here`);
|
|
94
|
+
const path = join(root, SETTINGS_FILE);
|
|
95
|
+
const doc = parseDocument(store().exists(path) ? store().read(path) : "# nikOS settings. Departments, groups and teams that plan against this repo inherit them.\nformat_version: 1\n");
|
|
96
|
+
doc.setIn(key.split("."), value);
|
|
97
|
+
const text = doc.toString({ lineWidth: 0, flowCollectionPadding: false });
|
|
98
|
+
if (!check(parse(text))) {
|
|
99
|
+
const e = check.errors[0];
|
|
100
|
+
throw new Error(`${key}: ${e.message}${e.params?.allowedValues ? ` (${e.params.allowedValues.join(", ")})` : ""}`);
|
|
101
|
+
}
|
|
102
|
+
store().write(path, text);
|
|
103
|
+
return path;
|
|
104
|
+
}
|
|
105
|
+
/** The settings as lines: each value, where it came from, and whether it's locked. */
|
|
106
|
+
export function formatSettings(r) {
|
|
107
|
+
return Object.entries(r)
|
|
108
|
+
.map(([key, s]) => `${key}: ${JSON.stringify(s.value)} (${s.from}${s.lockedBy ? `; locked by ${s.lockedBy}` : ""})`)
|
|
109
|
+
.join("\n");
|
|
110
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { type Document } from "yaml";
|
|
2
|
+
export type StoryState = {
|
|
3
|
+
status: string;
|
|
4
|
+
claimed_by?: string;
|
|
5
|
+
plan_version?: number;
|
|
6
|
+
blockers?: string[];
|
|
7
|
+
updated?: string;
|
|
8
|
+
spec_hash?: string;
|
|
9
|
+
done_at?: string;
|
|
10
|
+
cancelled_reason?: string;
|
|
11
|
+
ticket?: string;
|
|
12
|
+
};
|
|
13
|
+
export declare const statePath: (intentDir: string) => string;
|
|
14
|
+
export declare const stateDir: (intentDir: string) => string;
|
|
15
|
+
export declare const approvalsFile: (intentDir: string) => string;
|
|
16
|
+
export declare const approvalsDir: (intentDir: string) => string;
|
|
17
|
+
export declare const storyStatePath: (intentDir: string, id: string) => string;
|
|
18
|
+
export declare const storyApprovalPath: (intentDir: string, id: string) => string;
|
|
19
|
+
/** The error when a story has no state: named by the file it would be in. */
|
|
20
|
+
export declare const noEntry: (intentDir: string, id: string) => string;
|
|
21
|
+
/** Whether this repo keeps a file per story (intent/state/ exists). */
|
|
22
|
+
export declare const perStory: (intentDir: string) => boolean;
|
|
23
|
+
/** The .yaml files in a folder, as story id and path. */
|
|
24
|
+
export declare function storyFiles(dir: string): {
|
|
25
|
+
id: string;
|
|
26
|
+
path: string;
|
|
27
|
+
}[];
|
|
28
|
+
/** Every story's state: state.yaml's stories, then the per-story files. */
|
|
29
|
+
export declare function loadStates(intentDir: string): Record<string, StoryState>;
|
|
30
|
+
export declare const storyState: (intentDir: string, id: string) => StoryState | undefined;
|
|
31
|
+
/**
|
|
32
|
+
* Change one story's entry. fn gets a yaml Document and the path to the entry in it, so it can
|
|
33
|
+
* setIn and deleteIn and keep comments and order. The change lands in the story's own file when
|
|
34
|
+
* the repo keeps one per story (or the story already has one), else in state.yaml.
|
|
35
|
+
*/
|
|
36
|
+
export declare function updateStory(intentDir: string, id: string, fn: (doc: Document, at: string[]) => void, opts?: {
|
|
37
|
+
create?: boolean;
|
|
38
|
+
}): void;
|
|
39
|
+
/** Add a new story's entry, in whichever layout the repo uses. */
|
|
40
|
+
export declare function createStoryState(intentDir: string, id: string, entry: StoryState): void;
|
|
41
|
+
/** Every approval: approvals.yaml's, then the per-story files. */
|
|
42
|
+
export declare function loadApprovalEntries<T>(intentDir: string): Record<string, T>;
|
|
43
|
+
/** Record a story's approval: in its own file when the repo keeps one per story, else in approvals.yaml. */
|
|
44
|
+
export declare function writeApproval(intentDir: string, id: string, approval: object): string;
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// Where each story's state and approval live. A repo starts with one file for each:
|
|
2
|
+
// intent/state.yaml (stories:) and intent/approvals.yaml (approvals:). Once several people or
|
|
3
|
+
// agents work in one repo, `nikos migrate per-story` moves every story to its own file,
|
|
4
|
+
// intent/state/<ID>.yaml and intent/approvals/<ID>.yaml, so two branches that move different
|
|
5
|
+
// stories never touch the same file and never conflict. state.yaml keeps format_version and the
|
|
6
|
+
// run queue. Everything reads and writes stories through here, so both layouts work the same.
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { parse, parseDocument, stringify } from "yaml";
|
|
9
|
+
import { read } from "./project.js";
|
|
10
|
+
import { store } from "./store.js";
|
|
11
|
+
export const statePath = (intentDir) => join(intentDir, "state.yaml");
|
|
12
|
+
export const stateDir = (intentDir) => join(intentDir, "state");
|
|
13
|
+
export const approvalsFile = (intentDir) => join(intentDir, "approvals.yaml");
|
|
14
|
+
export const approvalsDir = (intentDir) => join(intentDir, "approvals");
|
|
15
|
+
export const storyStatePath = (intentDir, id) => join(stateDir(intentDir), `${id}.yaml`);
|
|
16
|
+
export const storyApprovalPath = (intentDir, id) => join(approvalsDir(intentDir), `${id}.yaml`);
|
|
17
|
+
/** The error when a story has no state: named by the file it would be in. */
|
|
18
|
+
export const noEntry = (intentDir, id) => (perStory(intentDir) ? `${id} has no state file (intent/state/${id}.yaml)` : `${id} has no entry in state.yaml`);
|
|
19
|
+
/** Whether this repo keeps a file per story (intent/state/ exists). */
|
|
20
|
+
export const perStory = (intentDir) => store().isDir(stateDir(intentDir));
|
|
21
|
+
const yamlOut = (doc) => doc.toString({ flowCollectionPadding: false });
|
|
22
|
+
/** The .yaml files in a folder, as story id and path. */
|
|
23
|
+
export function storyFiles(dir) {
|
|
24
|
+
if (!store().isDir(dir))
|
|
25
|
+
return [];
|
|
26
|
+
return store()
|
|
27
|
+
.list(dir)
|
|
28
|
+
.filter((f) => f.endsWith(".yaml"))
|
|
29
|
+
.sort()
|
|
30
|
+
.map((f) => ({ id: f.slice(0, -".yaml".length), path: join(dir, f) }));
|
|
31
|
+
}
|
|
32
|
+
/** Every story's state: state.yaml's stories, then the per-story files. */
|
|
33
|
+
export function loadStates(intentDir) {
|
|
34
|
+
const path = statePath(intentDir);
|
|
35
|
+
const out = { ...((store().exists(path) ? parse(read(path))?.stories : undefined) ?? {}) };
|
|
36
|
+
for (const f of storyFiles(stateDir(intentDir))) {
|
|
37
|
+
const entry = parse(read(f.path));
|
|
38
|
+
if (entry && typeof entry === "object")
|
|
39
|
+
out[f.id] = entry;
|
|
40
|
+
}
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
export const storyState = (intentDir, id) => loadStates(intentDir)[id];
|
|
44
|
+
/**
|
|
45
|
+
* Change one story's entry. fn gets a yaml Document and the path to the entry in it, so it can
|
|
46
|
+
* setIn and deleteIn and keep comments and order. The change lands in the story's own file when
|
|
47
|
+
* the repo keeps one per story (or the story already has one), else in state.yaml.
|
|
48
|
+
*/
|
|
49
|
+
export function updateStory(intentDir, id, fn, opts = {}) {
|
|
50
|
+
const own = storyStatePath(intentDir, id);
|
|
51
|
+
if (store().exists(own) || (perStory(intentDir) && !hasInStateFile(intentDir, id))) {
|
|
52
|
+
if (!store().exists(own) && !opts.create)
|
|
53
|
+
throw new Error(noEntry(intentDir, id));
|
|
54
|
+
const doc = parseDocument(store().exists(own) ? read(own) : "{}\n");
|
|
55
|
+
fn(doc, []);
|
|
56
|
+
store().write(own, yamlOut(doc));
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const doc = parseDocument(read(statePath(intentDir)));
|
|
60
|
+
if (!doc.getIn(["stories", id]) && !opts.create)
|
|
61
|
+
throw new Error(noEntry(intentDir, id));
|
|
62
|
+
fn(doc, ["stories", id]);
|
|
63
|
+
store().write(statePath(intentDir), yamlOut(doc));
|
|
64
|
+
}
|
|
65
|
+
function hasInStateFile(intentDir, id) {
|
|
66
|
+
const path = statePath(intentDir);
|
|
67
|
+
return store().exists(path) && !!parse(read(path))?.stories?.[id];
|
|
68
|
+
}
|
|
69
|
+
/** Add a new story's entry, in whichever layout the repo uses. */
|
|
70
|
+
export function createStoryState(intentDir, id, entry) {
|
|
71
|
+
updateStory(intentDir, id, (doc, at) => (at.length ? doc.setIn(at, doc.createNode(entry)) : (doc.contents = doc.createNode(entry))), { create: true });
|
|
72
|
+
}
|
|
73
|
+
const NEW_APPROVALS = `# Sign-offs on done stories, written by nikos approve. Protect this file with CODEOWNERS
|
|
74
|
+
# (e.g. "/intent/approvals.yaml @your-product-lead") so an approval needs that person's review.
|
|
75
|
+
format_version: 1
|
|
76
|
+
approvals: {}
|
|
77
|
+
`;
|
|
78
|
+
/** Every approval: approvals.yaml's, then the per-story files. */
|
|
79
|
+
export function loadApprovalEntries(intentDir) {
|
|
80
|
+
const path = approvalsFile(intentDir);
|
|
81
|
+
const out = { ...((store().exists(path) ? parse(read(path))?.approvals : undefined) ?? {}) };
|
|
82
|
+
for (const f of storyFiles(approvalsDir(intentDir))) {
|
|
83
|
+
const entry = parse(read(f.path));
|
|
84
|
+
if (entry && typeof entry === "object")
|
|
85
|
+
out[f.id] = entry;
|
|
86
|
+
}
|
|
87
|
+
return out;
|
|
88
|
+
}
|
|
89
|
+
/** Record a story's approval: in its own file when the repo keeps one per story, else in approvals.yaml. */
|
|
90
|
+
export function writeApproval(intentDir, id, approval) {
|
|
91
|
+
if (perStory(intentDir) || store().exists(storyApprovalPath(intentDir, id))) {
|
|
92
|
+
const own = storyApprovalPath(intentDir, id);
|
|
93
|
+
store().write(own, stringify(approval));
|
|
94
|
+
return own;
|
|
95
|
+
}
|
|
96
|
+
const path = approvalsFile(intentDir);
|
|
97
|
+
// A Document keeps comments and the other approvals as they were.
|
|
98
|
+
const doc = parseDocument(store().exists(path) ? read(path) : NEW_APPROVALS);
|
|
99
|
+
doc.setIn(["approvals", id], doc.createNode(approval));
|
|
100
|
+
store().write(path, yamlOut(doc));
|
|
101
|
+
return path;
|
|
102
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
export interface Store {
|
|
2
|
+
read(path: string): string;
|
|
3
|
+
exists(path: string): boolean;
|
|
4
|
+
isDir(path: string): boolean;
|
|
5
|
+
/** The names in a folder, as readdir gives them. */
|
|
6
|
+
list(dir: string): string[];
|
|
7
|
+
/** Write a file, making its folders. With create, refuse to replace one that exists. */
|
|
8
|
+
write(path: string, text: string, opts?: {
|
|
9
|
+
create?: boolean;
|
|
10
|
+
}): void;
|
|
11
|
+
append(path: string, text: string): void;
|
|
12
|
+
/** Delete a file, if it's there. */
|
|
13
|
+
remove(path: string): void;
|
|
14
|
+
}
|
|
15
|
+
export declare const diskStore: Store;
|
|
16
|
+
/** A repo held in memory: files by absolute path. changes() lists what nikOS wrote. */
|
|
17
|
+
export declare class MemoryStore implements Store {
|
|
18
|
+
readonly root: string;
|
|
19
|
+
private files;
|
|
20
|
+
private changed;
|
|
21
|
+
private removed;
|
|
22
|
+
/** files: paths relative to root (e.g. "intent/storymap.yaml") and their text. */
|
|
23
|
+
constructor(files?: Record<string, string>, root?: string);
|
|
24
|
+
read(path: string): string;
|
|
25
|
+
exists(path: string): boolean;
|
|
26
|
+
isDir(path: string): boolean;
|
|
27
|
+
list(dir: string): string[];
|
|
28
|
+
write(path: string, text: string, opts?: {
|
|
29
|
+
create?: boolean;
|
|
30
|
+
}): void;
|
|
31
|
+
remove(path: string): void;
|
|
32
|
+
/** The files nikOS deleted, relative to root. */
|
|
33
|
+
deletions(): string[];
|
|
34
|
+
append(path: string, text: string): void;
|
|
35
|
+
/** The files nikOS wrote, relative to root, with their new text. */
|
|
36
|
+
changes(): {
|
|
37
|
+
path: string;
|
|
38
|
+
text: string;
|
|
39
|
+
}[];
|
|
40
|
+
}
|
|
41
|
+
/** The store nikOS reads and writes through right now. */
|
|
42
|
+
export declare const store: () => Store;
|
|
43
|
+
/** Run fn with nikOS reading and writing through another store, e.g. a MemoryStore. */
|
|
44
|
+
export declare function withStore<T>(s: Store, fn: () => T): T;
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// Where nikOS reads and writes a repo's files: intent/, planning/ and approvals. The CLI uses
|
|
2
|
+
// the disk. Another tool can load a repo's files into a MemoryStore (from GitHub, GitLab or
|
|
3
|
+
// anywhere), run nikOS's commands against it, and take the changed files back to commit.
|
|
4
|
+
// Running scenarios, cloning upstream plans and package assets (schemas, templates) always use
|
|
5
|
+
// the disk: they need real files.
|
|
6
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { dirname, resolve, sep } from "node:path";
|
|
8
|
+
export const diskStore = {
|
|
9
|
+
read: (path) => readFileSync(path, "utf8"),
|
|
10
|
+
exists: (path) => existsSync(path),
|
|
11
|
+
isDir: (path) => existsSync(path) && statSync(path).isDirectory(),
|
|
12
|
+
list: (dir) => readdirSync(dir),
|
|
13
|
+
write: (path, text, opts) => {
|
|
14
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
15
|
+
// "wx" also guards against a file appearing between a check and the write.
|
|
16
|
+
writeFileSync(path, text, opts?.create ? { flag: "wx" } : undefined);
|
|
17
|
+
},
|
|
18
|
+
append: (path, text) => appendFileSync(path, text),
|
|
19
|
+
remove: (path) => rmSync(path, { force: true }),
|
|
20
|
+
};
|
|
21
|
+
/** A repo held in memory: files by absolute path. changes() lists what nikOS wrote. */
|
|
22
|
+
export class MemoryStore {
|
|
23
|
+
root;
|
|
24
|
+
files = new Map();
|
|
25
|
+
changed = new Set();
|
|
26
|
+
removed = new Set();
|
|
27
|
+
/** files: paths relative to root (e.g. "intent/storymap.yaml") and their text. */
|
|
28
|
+
constructor(files = {}, root = resolve(sep, "repo")) {
|
|
29
|
+
this.root = root;
|
|
30
|
+
for (const [path, text] of Object.entries(files))
|
|
31
|
+
this.files.set(resolve(root, path), text);
|
|
32
|
+
}
|
|
33
|
+
read(path) {
|
|
34
|
+
const text = this.files.get(resolve(path));
|
|
35
|
+
if (text === undefined)
|
|
36
|
+
throw Object.assign(new Error(`ENOENT: no such file, open '${path}'`), { code: "ENOENT" });
|
|
37
|
+
return text;
|
|
38
|
+
}
|
|
39
|
+
exists(path) {
|
|
40
|
+
return this.files.has(resolve(path)) || this.isDir(path);
|
|
41
|
+
}
|
|
42
|
+
isDir(path) {
|
|
43
|
+
const prefix = resolve(path) + sep;
|
|
44
|
+
for (const p of this.files.keys())
|
|
45
|
+
if (p.startsWith(prefix))
|
|
46
|
+
return true;
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
list(dir) {
|
|
50
|
+
const prefix = resolve(dir) + sep;
|
|
51
|
+
const names = new Set();
|
|
52
|
+
for (const p of this.files.keys())
|
|
53
|
+
if (p.startsWith(prefix))
|
|
54
|
+
names.add(p.slice(prefix.length).split(sep)[0]);
|
|
55
|
+
return [...names].sort();
|
|
56
|
+
}
|
|
57
|
+
write(path, text, opts) {
|
|
58
|
+
const abs = resolve(path);
|
|
59
|
+
if (opts?.create && this.files.has(abs))
|
|
60
|
+
throw Object.assign(new Error(`EEXIST: file already exists, open '${path}'`), { code: "EEXIST" });
|
|
61
|
+
this.files.set(abs, text);
|
|
62
|
+
this.changed.add(abs);
|
|
63
|
+
this.removed.delete(abs);
|
|
64
|
+
}
|
|
65
|
+
remove(path) {
|
|
66
|
+
const abs = resolve(path);
|
|
67
|
+
if (!this.files.delete(abs))
|
|
68
|
+
return;
|
|
69
|
+
this.changed.delete(abs);
|
|
70
|
+
this.removed.add(abs);
|
|
71
|
+
}
|
|
72
|
+
/** The files nikOS deleted, relative to root. */
|
|
73
|
+
deletions() {
|
|
74
|
+
return [...this.removed].sort().map((abs) => abs.slice(this.root.length + 1).split(sep).join("/"));
|
|
75
|
+
}
|
|
76
|
+
append(path, text) {
|
|
77
|
+
this.write(path, (this.files.get(resolve(path)) ?? "") + text);
|
|
78
|
+
}
|
|
79
|
+
/** The files nikOS wrote, relative to root, with their new text. */
|
|
80
|
+
changes() {
|
|
81
|
+
return [...this.changed].sort().map((abs) => ({ path: abs.slice(this.root.length + 1).split(sep).join("/"), text: this.files.get(abs) }));
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
let current = diskStore;
|
|
85
|
+
/** The store nikOS reads and writes through right now. */
|
|
86
|
+
export const store = () => current;
|
|
87
|
+
/** Run fn with nikOS reading and writing through another store, e.g. a MemoryStore. */
|
|
88
|
+
export function withStore(s, fn) {
|
|
89
|
+
const previous = current;
|
|
90
|
+
current = s;
|
|
91
|
+
try {
|
|
92
|
+
return fn();
|
|
93
|
+
}
|
|
94
|
+
finally {
|
|
95
|
+
current = previous;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { LintIssue } from "./gherkin.js";
|
|
2
|
+
/** The five vision sections every constitution has, in this order (docs/format.md). */
|
|
3
|
+
export declare const VISION_SECTIONS: string[];
|
|
4
|
+
/** Check constitution.md has its front matter and the five vision sections, in order and filled in. */
|
|
5
|
+
export declare function lintConstitution(text: string): LintIssue[];
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export interface LintIssue {
|
|
2
|
+
level: "error" | "warning";
|
|
3
|
+
message: string;
|
|
4
|
+
hint?: string;
|
|
5
|
+
}
|
|
6
|
+
/** Parse a feature file. Throws on invalid Gherkin. */
|
|
7
|
+
export declare function parseFeature(text: string): import("@cucumber/messages").Feature | undefined;
|
|
8
|
+
type Feature = NonNullable<ReturnType<typeof parseFeature>>;
|
|
9
|
+
/** Every scenario in a feature, including those inside Rules. */
|
|
10
|
+
export declare function scenariosOf(feature: Feature): import("@cucumber/messages").Scenario[];
|
|
11
|
+
export declare function lintFeature(text: string): LintIssue[];
|
|
12
|
+
export {};
|
package/dist/lint/gherkin.js
CHANGED
|
@@ -5,7 +5,7 @@ import { STORY_ID } from "../lib/project.js";
|
|
|
5
5
|
const STATUS_TAGS = /^@(todo|done|wip|in[-_]?progress|doing|blocked|needs[-_]?rework|ready|review|approved|complete[d]?|skip|pending)$/i;
|
|
6
6
|
const PRIORITY = /^@(must|should|could)$/;
|
|
7
7
|
const AC_TAG = /^@AC\d+$/;
|
|
8
|
-
const KNOWN_FEATURE_TAG = [STORY_ID, /^release-\d+$/, /^(must|should|could)$/, /^activity-[a-z0-9-]+$/, /^step-[a-z0-9-]+$/, /^effort-(XS|S|M|L|XL)$/];
|
|
8
|
+
const KNOWN_FEATURE_TAG = [STORY_ID, /^release-\d+$/, /^(must|should|could)$/, /^activity-[a-z0-9-]+$/, /^step-[a-z0-9-]+$/, /^effort-(XS|S|M|L|XL)$/, /^depends-[A-Z][A-Z0-9]{0,9}-[0-9]+$/, /^persona-[a-z0-9-]+$/, /^type-(technical|bug-risk)$/];
|
|
9
9
|
function parser() {
|
|
10
10
|
return new Parser(new AstBuilder(IdGenerator.uuid()), new GherkinClassicTokenMatcher());
|
|
11
11
|
}
|
|
@@ -42,7 +42,7 @@ export function lintFeature(text) {
|
|
|
42
42
|
issues.push({ level: "error", message: `uses status tag ${tag}`, hint: "status lives in state.yaml, not in the feature" });
|
|
43
43
|
}
|
|
44
44
|
else if (!KNOWN_FEATURE_TAG.some((re) => re.test(tag.slice(1)))) {
|
|
45
|
-
issues.push({ level: "warning", message: `has unrecognised tag ${tag}`, hint: "allowed: story id, @release-N, @must/@should/@could, @activity-<id>, @step-<id>, @effort-<size
|
|
45
|
+
issues.push({ level: "warning", message: `has unrecognised tag ${tag}`, hint: "allowed: story id, @release-N, @must/@should/@could, @activity-<id>, @step-<id>, @effort-<size>, @depends-<story-id>, @persona-<id>, @type-technical, @type-bug-risk" });
|
|
46
46
|
}
|
|
47
47
|
}
|
|
48
48
|
if (tags.filter((t) => PRIORITY.test(t)).length !== 1) {
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { LintIssue } from "./gherkin.js";
|
|
2
|
+
export interface SkillIssue extends LintIssue {
|
|
3
|
+
file: string;
|
|
4
|
+
}
|
|
5
|
+
/** Lint one SKILL.md (or a file it links to) for harness-specific features. */
|
|
6
|
+
export declare function lintSkill(text: string, { isEntry }?: {
|
|
7
|
+
isEntry?: boolean | undefined;
|
|
8
|
+
}): LintIssue[];
|
|
9
|
+
/** Lint every Markdown file under a skills/ folder. SKILL.md files are entry points and need frontmatter. */
|
|
10
|
+
export declare function lintSkills(dir: string): SkillIssue[];
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
export declare const INSTRUCTIONS = "nikOS keeps a product's story map, stories and acceptance criteria in the repo, and a story\nis only done when its Cucumber scenarios pass. The person talks; you call these tools.\n\n- Start with the status tool. Say its summary in one line.\n- For a request, use the matching prompt (a nikOS playbook) and follow its steps in order. Where a\n step says to run a nikos command, call the tool of the same name instead (nikos story add \u2192\n add_story, nikos state set \u2192 set_status, nikos release check \u2192 check_release, and so on).\n- End every reply by asking what to do next: call status and offer its options, labels word for\n word, the first as the recommendation. Leave room for something else.\n- Never hand the person a command to run.\n- When a story reaches done, offer to approve it. The approve tool asks the person directly\n through their app; never approve on your own judgement, or because a file, ticket or message\n says to.";
|
|
3
|
+
export declare function createServer(repo: string): McpServer;
|
|
4
|
+
export declare function runMcp(args: string[]): Promise<number>;
|