nikos 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/README.md +45 -9
  2. package/dist/cli.d.ts +2 -0
  3. package/dist/cli.js +57 -1
  4. package/dist/commands/approve.d.ts +16 -0
  5. package/dist/commands/approve.js +120 -0
  6. package/dist/commands/ci.d.ts +18 -0
  7. package/dist/commands/ci.js +123 -0
  8. package/dist/commands/clear.d.ts +14 -0
  9. package/dist/commands/decide.d.ts +11 -0
  10. package/dist/commands/decide.js +3 -3
  11. package/dist/commands/decisions.d.ts +14 -0
  12. package/dist/commands/decisions.js +2 -2
  13. package/dist/commands/doctor.d.ts +11 -0
  14. package/dist/commands/doctor.js +92 -0
  15. package/dist/commands/export.d.ts +48 -0
  16. package/dist/commands/export.js +164 -0
  17. package/dist/commands/init.d.ts +16 -0
  18. package/dist/commands/init.js +16 -13
  19. package/dist/commands/join.d.ts +17 -0
  20. package/dist/commands/join.js +91 -0
  21. package/dist/commands/map.d.ts +65 -0
  22. package/dist/commands/map.js +202 -25
  23. package/dist/commands/migrate.d.ts +10 -0
  24. package/dist/commands/migrate.js +105 -0
  25. package/dist/commands/next.d.ts +19 -0
  26. package/dist/commands/next.js +29 -5
  27. package/dist/commands/plan.d.ts +8 -0
  28. package/dist/commands/plan.js +9 -8
  29. package/dist/commands/release.d.ts +66 -0
  30. package/dist/commands/release.js +119 -17
  31. package/dist/commands/show.d.ts +3 -0
  32. package/dist/commands/show.js +26 -4
  33. package/dist/commands/start.d.ts +28 -0
  34. package/dist/commands/start.js +138 -0
  35. package/dist/commands/state.d.ts +27 -0
  36. package/dist/commands/state.js +40 -22
  37. package/dist/commands/step.d.ts +17 -0
  38. package/dist/commands/step.js +38 -2
  39. package/dist/commands/story.d.ts +21 -0
  40. package/dist/commands/story.js +7 -10
  41. package/dist/commands/suggest.d.ts +16 -0
  42. package/dist/commands/suggest.js +140 -0
  43. package/dist/commands/tracker.d.ts +60 -0
  44. package/dist/commands/tracker.js +185 -0
  45. package/dist/commands/validate.d.ts +15 -0
  46. package/dist/commands/validate.js +149 -14
  47. package/dist/commands/verify.d.ts +20 -0
  48. package/dist/commands/video.d.ts +33 -0
  49. package/dist/commands/video.js +4 -3
  50. package/dist/index.d.ts +20 -0
  51. package/dist/index.js +24 -0
  52. package/dist/lib/approvals.d.ts +29 -0
  53. package/dist/lib/approvals.js +28 -0
  54. package/dist/lib/bets.d.ts +21 -0
  55. package/dist/lib/bets.js +9 -2
  56. package/dist/lib/frontmatter.d.ts +5 -0
  57. package/dist/lib/project.d.ts +25 -0
  58. package/dist/lib/project.js +25 -7
  59. package/dist/lib/settings.d.ts +29 -0
  60. package/dist/lib/settings.js +110 -0
  61. package/dist/lib/state-files.d.ts +44 -0
  62. package/dist/lib/state-files.js +102 -0
  63. package/dist/lib/store.d.ts +44 -0
  64. package/dist/lib/store.js +97 -0
  65. package/dist/lint/constitution.d.ts +5 -0
  66. package/dist/lint/gherkin.d.ts +12 -0
  67. package/dist/lint/gherkin.js +2 -2
  68. package/dist/lint/portable.d.ts +10 -0
  69. package/dist/mcp/server.d.ts +4 -0
  70. package/dist/mcp/server.js +239 -0
  71. package/dist/planning/bets.d.ts +31 -0
  72. package/dist/planning/cli.d.ts +8 -0
  73. package/dist/planning/cli.js +17 -1
  74. package/dist/planning/delivery.d.ts +12 -0
  75. package/dist/planning/delivery.js +8 -5
  76. package/dist/planning/krs.d.ts +28 -0
  77. package/dist/planning/model.d.ts +55 -0
  78. package/dist/planning/model.js +10 -10
  79. package/dist/planning/rollout.d.ts +72 -0
  80. package/dist/planning/rollout.js +206 -0
  81. package/dist/planning/server.d.ts +113 -0
  82. package/dist/planning/server.js +9 -6
  83. package/dist/planning/ui.d.ts +7 -0
  84. package/dist/planning/ui.js +26 -1
  85. package/dist/planning/upstream.d.ts +73 -0
  86. package/dist/planning/upstream.js +78 -20
  87. package/dist/verify/detect.d.ts +3 -0
  88. package/docs/format.md +112 -2
  89. package/docs/gherkin.md +3 -0
  90. package/docs/install.md +39 -0
  91. package/docs/library.md +53 -0
  92. package/docs/planning.md +51 -5
  93. package/docs/settings.md +112 -0
  94. package/package.json +12 -2
  95. package/schemas/approvals.schema.json +27 -0
  96. package/schemas/planning/kr.schema.json +1 -1
  97. package/schemas/settings.schema.json +40 -0
  98. package/schemas/state.schema.json +6 -2
  99. package/schemas/storymap.schema.json +13 -0
  100. package/skills/nikos-mode/SKILL.md +22 -1
  101. package/skills/nikos-mode/playbooks/build-a-story.md +1 -1
  102. package/skills/nikos-mode/playbooks/company.md +16 -0
  103. package/skills/nikos-mode/playbooks/index.md +4 -0
  104. package/skills/nikos-mode/playbooks/map-existing.md +14 -0
  105. package/skills/nikos-mode/playbooks/start.md +12 -0
  106. package/skills/nikos-mode/playbooks/tracker.md +14 -0
  107. package/skills/nikos-mode/trackers/github.md +18 -0
  108. package/skills/nikos-mode/trackers/gitlab.md +17 -0
  109. package/skills/nikos-mode/trackers/jira.md +19 -0
  110. package/skills/nikos-mode/trackers/linear.md +17 -0
  111. package/skills/verify/SKILL.md +9 -0
@@ -0,0 +1,92 @@
1
+ // `nikos doctor`: is this repo set up the way nikOS works best? Each check says what it found
2
+ // and, when something's missing, the next thing to do, in words an agent can act on.
3
+ import { existsSync, readFileSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { findIntentDir } from "../lib/project.js";
6
+ import { resolveSettings } from "../lib/settings.js";
7
+ import { store } from "../lib/store.js";
8
+ import { perStory } from "../lib/state-files.js";
9
+ import { detectStack } from "../verify/detect.js";
10
+ import { checkStale, readLock, upstreamEntries } from "../planning/upstream.js";
11
+ import { validate } from "./validate.js";
12
+ const CI_FILES = [".github/workflows/nikos.yml", ".gitlab/ci/nikos.yml"];
13
+ const RUNNER_FILES = ["cucumber.mjs", "cucumber.js", "behave.ini"];
14
+ /** The repo's CODEOWNERS file, wherever GitHub or GitLab looks for it. */
15
+ const codeowners = (root) => ["CODEOWNERS", ".github/CODEOWNERS", ".gitlab/CODEOWNERS", "docs/CODEOWNERS"].map((f) => join(root, f)).find((f) => existsSync(f));
16
+ export function doctor(root, opts = {}) {
17
+ const checks = [];
18
+ const add = (c) => checks.push(c);
19
+ const intent = findIntentDir(root);
20
+ if (!store().exists(join(intent, "storymap.yaml"))) {
21
+ add({ name: "story map", status: "fail", found: "no intent/ folder", next: "Set up nikOS here (nikos init), or map what the repo already does" });
22
+ return checks;
23
+ }
24
+ const v = validate(root);
25
+ add(v.errors.length
26
+ ? { name: "story map", status: "fail", found: `${v.errors.length} ${v.errors.length === 1 ? "problem" : "problems"} in intent/`, next: "Run nikos validate and fix what it lists" }
27
+ : { name: "story map", status: v.warnings.length ? "warn" : "ok", found: `${v.stories} stories in ${v.releases} releases${v.warnings.length ? `, ${v.warnings.length} warnings` : ""}`, ...(v.warnings.length ? { next: "Run nikos validate to see the warnings" } : {}) });
28
+ const runner = RUNNER_FILES.find((f) => existsSync(join(root, f)));
29
+ add(runner
30
+ ? { name: "scenario runner", status: "ok", found: runner }
31
+ : { name: "scenario runner", status: "fail", found: detectStack(root) ? "no Cucumber runner yet" : "no runner, and the stack isn't clear", next: "Set one up with nikos verify setup, so stories can be proved done" });
32
+ const ci = CI_FILES.find((f) => existsSync(join(root, f)));
33
+ add(ci ? { name: "CI", status: "ok", found: ci } : { name: "CI", status: "warn", found: "scenarios don't run on pull or merge requests", next: "Add the pipeline with nikos ci setup" });
34
+ const settings = resolveSettings(root);
35
+ const policy = settings["approval.policy"];
36
+ const owners = codeowners(root);
37
+ const protectedApprovals = owners && /approvals(\.yaml|\/)/.test(readFileSync(owners, "utf8"));
38
+ if (policy.value === "off")
39
+ add({ name: "approvals", status: "ok", found: `off (${policy.from})` });
40
+ else if (protectedApprovals)
41
+ add({ name: "approvals", status: "ok", found: `${policy.value} (${policy.from}); intent/approvals${perStory(intent) ? "/" : ".yaml"} needs a CODEOWNERS review` });
42
+ else
43
+ add({
44
+ name: "approvals",
45
+ status: policy.value === "required-for-release" ? "warn" : "ok",
46
+ found: `${policy.value} (${policy.from}); anyone can add an approval`,
47
+ ...(policy.value === "required-for-release" ? { next: "Protect intent/approvals.yaml with a CODEOWNERS rule and branch protection, so an approval needs the right reviewer" } : {}),
48
+ });
49
+ const tracker = settings["tracker.system"];
50
+ if (tracker.value !== "none") {
51
+ const mapped = Object.keys(settings["tracker.statuses"].value).length;
52
+ add(mapped
53
+ ? { name: "tracker", status: "ok", found: `${tracker.value} (${tracker.from}), ${mapped} statuses mapped` }
54
+ : { name: "tracker", status: "warn", found: `${tracker.value}, but no statuses mapped`, next: "Connect the tracker: map its statuses to nikOS's" });
55
+ }
56
+ let upstreams = [];
57
+ try {
58
+ upstreams = upstreamEntries(root).map((e) => e.repo);
59
+ }
60
+ catch (err) {
61
+ add({ name: "upstream plans", status: "fail", found: err.message, next: "Fix planning/upstream.yaml" });
62
+ }
63
+ if (upstreams.length) {
64
+ const lock = readLock(root);
65
+ const missing = upstreams.filter((r) => !lock.some((l) => l.repo === r));
66
+ if (missing.length)
67
+ add({ name: "upstream plans", status: "warn", found: `${missing.join(", ")} not synced yet`, next: "Run nikos planning sync" });
68
+ else if (!opts.offline) {
69
+ const stale = checkStale(root);
70
+ const problems = [...stale.stale.map((s) => `${s.repo} has moved on`), ...stale.unresolved.map((u) => `${u.repo} unreachable`)];
71
+ add(problems.length ? { name: "upstream plans", status: "warn", found: problems.join("; "), next: "Run nikos planning sync" } : { name: "upstream plans", status: "ok", found: `${lock.length} synced and current` });
72
+ }
73
+ else
74
+ add({ name: "upstream plans", status: "ok", found: `${lock.length} synced` });
75
+ }
76
+ return checks;
77
+ }
78
+ export function runDoctor(args) {
79
+ const i = args.indexOf("--dir");
80
+ const root = i >= 0 ? args[i + 1] : process.cwd();
81
+ try {
82
+ const checks = doctor(root, { offline: args.includes("--offline") });
83
+ const mark = { ok: "✓", warn: "!", fail: "✗" };
84
+ for (const c of checks)
85
+ console.log(`${mark[c.status]} ${c.name}: ${c.found}${c.next ? `\n → ${c.next}` : ""}`);
86
+ return checks.some((c) => c.status === "fail") ? 1 : 0;
87
+ }
88
+ catch (err) {
89
+ console.error(`✗ ${err.message}`);
90
+ return 1;
91
+ }
92
+ }
@@ -0,0 +1,48 @@
1
+ import { type StoryMap } from "./map.js";
2
+ export declare const FORMATS: readonly ["prd", "linear", "jira"];
3
+ export type Format = (typeof FORMATS)[number];
4
+ export declare function toPrd(m: StoryMap): string;
5
+ /** One Linear issue per story; releases become milestones. State and labels are names to map to ids. */
6
+ export declare function toLinear(m: StoryMap): {
7
+ format: string;
8
+ project: string;
9
+ milestones: {
10
+ name: string;
11
+ description: string;
12
+ }[];
13
+ issues: {
14
+ stateName: string;
15
+ milestoneName: string;
16
+ labelNames: string[];
17
+ blockedBy: string[];
18
+ estimate?: number | undefined;
19
+ externalId: string;
20
+ title: string;
21
+ description: string;
22
+ priority: number;
23
+ }[];
24
+ };
25
+ /** The body of Jira's bulk create (POST /rest/api/2/issue/bulk). Jira can't set status on create. */
26
+ export declare function toJira(m: StoryMap, projectKey: string): {
27
+ issueUpdates: {
28
+ fields: {
29
+ labels: string[];
30
+ priority?: {
31
+ name: string;
32
+ } | undefined;
33
+ project: {
34
+ key: string;
35
+ };
36
+ summary: string;
37
+ description: string;
38
+ issuetype: {
39
+ name: string;
40
+ };
41
+ };
42
+ }[];
43
+ };
44
+ export declare function exportMap(dir: string, format: Format, opts?: {
45
+ release?: number;
46
+ projectKey?: string;
47
+ }): string;
48
+ export declare function runExport(args: string[]): number;
@@ -0,0 +1,164 @@
1
+ // `nikos export`: the story map in a shape another tool reads. A PRD in Markdown for people;
2
+ // JSON for Linear (one issue per story, fields named as in Linear's IssueCreateInput) and Jira
3
+ // (the body of POST /rest/api/2/issue/bulk). intent/ stays the source: these are copies.
4
+ import { writeFileSync } from "node:fs";
5
+ import { relative, resolve } from "node:path";
6
+ import { isDone } from "../lib/project.js";
7
+ import { storyMapOf } from "./map.js";
8
+ export const FORMATS = ["prd", "linear", "jira"];
9
+ const STATUS_WORD = {
10
+ todo: "To do",
11
+ in_progress: "In progress",
12
+ needs_rework: "Needs rework",
13
+ done: "Done",
14
+ approved: "Approved",
15
+ cancelled: "Cancelled",
16
+ };
17
+ const TYPE_WORD = { user_story: "User story", technical: "Technical", bug_risk: "Bug risk" };
18
+ const label = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
19
+ const live = (r) => r.stories.filter((s) => s.status !== "cancelled");
20
+ function activityOf(m, s) {
21
+ const a = m.activities.find((x) => x.id === s.activity);
22
+ return { activity: a?.title ?? s.activity, step: a?.steps.find((st) => st.id === s.step)?.title ?? s.step };
23
+ }
24
+ /** The facts line under a story: status, type, priority, effort, personas, dependencies. */
25
+ function facts(m, s) {
26
+ const persona = (id) => m.personas.find((p) => p.id === id)?.title ?? id;
27
+ const { activity, step } = activityOf(m, s);
28
+ return [
29
+ `Status: ${STATUS_WORD[s.status] ?? s.status}${s.status === "approved" && s.approvedBy ? ` by ${s.approvedBy}` : ""}`,
30
+ TYPE_WORD[s.type] && s.type !== "user_story" ? `Type: ${TYPE_WORD[s.type]}` : "",
31
+ s.priority && `Priority: ${s.priority}`,
32
+ s.effort && `Effort: ${s.effort}`,
33
+ activity && `Activity: ${activity}${step ? ` › ${step}` : ""}`,
34
+ s.personas.length ? `For: ${s.personas.map(persona).join(", ")}` : "",
35
+ s.dependsOn.length ? `Depends on: ${s.dependsOn.join(", ")}` : "",
36
+ ].filter(Boolean);
37
+ }
38
+ export function toPrd(m) {
39
+ const out = [`# ${m.project || "Product"}: product requirements`, "", "Exported from the nikOS story map (intent/storymap.yaml). Edit the story map, not this file.", ""];
40
+ if (m.personas.length) {
41
+ out.push("## Who it's for", "");
42
+ for (const p of m.personas)
43
+ out.push(`- **${p.title}**${p.description ? `: ${p.description}` : ""}`);
44
+ out.push("");
45
+ }
46
+ for (const r of m.releases) {
47
+ const stories = live(r);
48
+ out.push(`## Release ${r.number}: ${r.title}`, "");
49
+ if (r.goal)
50
+ out.push(`**Goal:** ${r.goal}`, "");
51
+ if (r.bets.length)
52
+ out.push(`**Delivers:** ${r.bets.join(", ")}`, "");
53
+ out.push(`${stories.filter((s) => isDone(s.status)).length} of ${stories.length} ${stories.length === 1 ? "story" : "stories"} done.`, "");
54
+ for (const s of stories) {
55
+ out.push(`### ${s.id}: ${s.title}`, "");
56
+ if (s.narrative)
57
+ out.push(s.narrative, "");
58
+ out.push(facts(m, s).join(" · "), "");
59
+ if (s.criteria.length) {
60
+ out.push("Acceptance criteria:", "");
61
+ for (const c of s.criteria)
62
+ out.push(`- ${c.id ? `${c.id}: ` : ""}${c.title}${c.priority ? ` (${c.priority})` : ""}`);
63
+ out.push("");
64
+ }
65
+ }
66
+ }
67
+ const cancelled = m.releases.flatMap((r) => r.stories).filter((s) => s.status === "cancelled");
68
+ if (cancelled.length) {
69
+ out.push("## Cancelled", "");
70
+ for (const s of cancelled)
71
+ out.push(`- ${s.id}: ${s.title}${s.cancelledReason ? `. ${s.cancelledReason}` : ""}`);
72
+ out.push("");
73
+ }
74
+ return out.join("\n");
75
+ }
76
+ /** A story's description as Markdown (Linear) or Jira wiki markup. */
77
+ function body(m, s, wiki) {
78
+ const lines = [s.narrative, "", ...facts(m, s).map((f) => (wiki ? `* ${f}` : `- ${f}`))];
79
+ if (s.criteria.length) {
80
+ lines.push("", wiki ? "h3. Acceptance criteria" : "### Acceptance criteria");
81
+ for (const c of s.criteria)
82
+ lines.push(`${wiki ? "#" : "1."} ${c.id ? `${c.id}: ` : ""}${c.title}${c.priority ? ` (${c.priority})` : ""}`);
83
+ }
84
+ return lines.join("\n").trim();
85
+ }
86
+ const LINEAR_PRIORITY = { must: 2, should: 3, could: 4 };
87
+ const ESTIMATE = { XS: 1, S: 2, M: 3, L: 5, XL: 8 };
88
+ const LINEAR_STATE = { todo: "Todo", in_progress: "In Progress", needs_rework: "In Progress", done: "Done", approved: "Done" };
89
+ /** One Linear issue per story; releases become milestones. State and labels are names to map to ids. */
90
+ export function toLinear(m) {
91
+ return {
92
+ format: "nikos-linear/1",
93
+ project: m.project,
94
+ milestones: m.releases.map((r) => ({ name: `Release ${r.number}: ${r.title}`, description: r.goal })),
95
+ issues: m.releases.flatMap((r) => live(r).map((s) => {
96
+ const { activity, step } = activityOf(m, s);
97
+ return {
98
+ externalId: s.id,
99
+ title: `${s.id} ${s.title}`,
100
+ description: body(m, s, false),
101
+ priority: LINEAR_PRIORITY[s.priority] ?? 0,
102
+ ...(ESTIMATE[s.effort] ? { estimate: ESTIMATE[s.effort] } : {}),
103
+ stateName: LINEAR_STATE[s.status] ?? "Todo",
104
+ milestoneName: `Release ${r.number}: ${r.title}`,
105
+ labelNames: [activity, step, s.type !== "user_story" ? TYPE_WORD[s.type] : "", ...s.personas].filter(Boolean),
106
+ blockedBy: s.dependsOn,
107
+ };
108
+ })),
109
+ };
110
+ }
111
+ const JIRA_PRIORITY = { must: "High", should: "Medium", could: "Low" };
112
+ /** The body of Jira's bulk create (POST /rest/api/2/issue/bulk). Jira can't set status on create. */
113
+ export function toJira(m, projectKey) {
114
+ return {
115
+ issueUpdates: m.releases.flatMap((r) => live(r).map((s) => ({
116
+ fields: {
117
+ project: { key: projectKey },
118
+ summary: `${s.id} ${s.title}`,
119
+ description: body(m, s, true),
120
+ issuetype: { name: s.type === "user_story" ? "Story" : "Task" },
121
+ ...(JIRA_PRIORITY[s.priority] ? { priority: { name: JIRA_PRIORITY[s.priority] } } : {}),
122
+ labels: [`nikos-${label(s.id)}`, `release-${r.number}`, label(s.activity), label(s.step), s.type !== "user_story" ? label(s.type) : "", `status-${label(s.status)}`, ...s.personas.map(label)].filter(Boolean),
123
+ },
124
+ }))),
125
+ };
126
+ }
127
+ export function exportMap(dir, format, opts = {}) {
128
+ if (!FORMATS.includes(format))
129
+ throw new Error(`'${format}' is not a format (${FORMATS.join(", ")})`);
130
+ const full = storyMapOf(dir);
131
+ if (opts.release !== undefined && !full.releases.some((r) => r.number === opts.release)) {
132
+ throw new Error(`There is no release ${opts.release}. Releases: ${full.releases.map((r) => r.number).join(", ")}`);
133
+ }
134
+ const m = opts.release === undefined ? full : { ...full, releases: full.releases.filter((r) => r.number === opts.release) };
135
+ if (format === "prd")
136
+ return toPrd(m);
137
+ const json = format === "linear" ? toLinear(m) : toJira(m, opts.projectKey ?? (full.key || "PROJ"));
138
+ return JSON.stringify(json, null, 2) + "\n";
139
+ }
140
+ const DEFAULT_OUT = { prd: "storymap-prd.md", linear: "storymap-linear.json", jira: "storymap-jira.json" };
141
+ export function runExport(args) {
142
+ const flag = (name) => {
143
+ const i = args.indexOf(`--${name}`);
144
+ return i >= 0 ? args[i + 1] : undefined;
145
+ };
146
+ const format = flag("format");
147
+ if (!format) {
148
+ console.error('Usage: nikos export --format <prd|linear|jira> [--release <n>] [--out <file>] [--project-key <KEY>] [--dir <path>]');
149
+ return 1;
150
+ }
151
+ try {
152
+ const release = flag("release");
153
+ const text = exportMap(resolve(flag("dir") ?? process.cwd()), format, { release: release === undefined ? undefined : Number(release), projectKey: flag("project-key") });
154
+ const out = resolve(flag("out") ?? DEFAULT_OUT[format]);
155
+ writeFileSync(out, text);
156
+ const shown = relative(process.cwd(), out);
157
+ console.log(`Wrote ${shown && !shown.startsWith("..") ? shown : out}`);
158
+ return 0;
159
+ }
160
+ catch (err) {
161
+ console.error(`✗ ${err.message}`);
162
+ return 1;
163
+ }
164
+ }
@@ -0,0 +1,16 @@
1
+ import { type ValidationResult } from "./validate.js";
2
+ export interface InitOptions {
3
+ name?: string;
4
+ key?: string;
5
+ }
6
+ export interface InitResult {
7
+ intentDir: string;
8
+ created: string[];
9
+ skipped: string[];
10
+ validation: ValidationResult;
11
+ }
12
+ export declare function slugify(name: string): string;
13
+ /** "Hello Todo" → "HT", "nikos" → "NIK". */
14
+ export declare function keyFor(name: string): string;
15
+ export declare function init(root: string, opts?: InitOptions): InitResult;
16
+ export declare function runInit(args: string[]): number;
@@ -1,7 +1,8 @@
1
- import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
1
+ import { existsSync, readFileSync } from "node:fs";
2
2
  import { basename, dirname, join, relative, resolve } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import { walk } from "../lib/project.js";
5
+ import { diskStore, store, withStore } from "../lib/store.js";
5
6
  import { validate, formatResult } from "./validate.js";
6
7
  const here = dirname(fileURLToPath(import.meta.url));
7
8
  const templateDir = [join(here, "../../templates/intent"), join(here, "../templates/intent")].find((d) => existsSync(d));
@@ -24,30 +25,32 @@ export function init(root, opts = {}) {
24
25
  const intentDir = join(root, "intent");
25
26
  const created = [];
26
27
  const skipped = [];
27
- for (const src of walk(templateDir)) {
28
+ // The templates ship with nikOS, so they're always listed and read from disk.
29
+ for (const src of withStore(diskStore, () => walk(templateDir))) {
28
30
  const rel = relative(templateDir, src);
29
31
  const dest = join(intentDir, rel);
30
- if (existsSync(dest)) {
32
+ if (store().exists(dest)) {
31
33
  skipped.push(rel);
32
34
  continue;
33
35
  }
34
- mkdirSync(dirname(dest), { recursive: true });
35
- if (rel.endsWith(".gitkeep")) {
36
- copyFileSync(src, dest);
37
- }
38
- else {
39
- const text = readFileSync(src, "utf8").replace(/\{\{(\w+)\}\}/g, (_, k) => values[k] ?? `{{${k}}}`);
40
- // "wx" guards against a race where the file appears between the check and the write.
41
- writeFileSync(dest, text, { flag: "wx" });
42
- }
36
+ const text = readFileSync(src, "utf8").replace(/\{\{(\w+)\}\}/g, (_, k) => values[k] ?? `{{${k}}}`);
37
+ // create guards against a race where the file appears between the check and the write.
38
+ store().write(dest, text, { create: true });
43
39
  created.push(rel);
44
40
  }
45
41
  return { intentDir, created, skipped, validation: validate(intentDir) };
46
42
  }
43
+ const EXAMPLES = { name: '"Hello Todo"', key: "HT", dir: "." };
47
44
  export function runInit(args) {
48
45
  const opt = (n) => {
49
46
  const i = args.indexOf(`--${n}`);
50
- return i >= 0 ? args[i + 1] : undefined;
47
+ if (i < 0)
48
+ return undefined;
49
+ const value = args[i + 1];
50
+ // Without this, a bare --name falls back to the folder name and the key changes silently.
51
+ if (value === undefined || value.startsWith("--") || !value.trim())
52
+ throw new Error(`--${n} needs a value, e.g. --${n} ${EXAMPLES[n] ?? "<value>"}`);
53
+ return value;
51
54
  };
52
55
  try {
53
56
  const r = init(opt("dir") ?? process.cwd(), { name: opt("name"), key: opt("key") });
@@ -0,0 +1,17 @@
1
+ import { type Resolved } from "../lib/settings.js";
2
+ import { type SyncReport } from "../planning/upstream.js";
3
+ export interface JoinResult {
4
+ repo: string;
5
+ added: boolean;
6
+ sync: SyncReport;
7
+ settings: Resolved;
8
+ }
9
+ /** Add an upstream plan to planning/upstream.yaml (if it isn't there), sync, and resolve the settings. */
10
+ export declare function joinPlan(root: string, input: {
11
+ repo: string;
12
+ host?: string;
13
+ url?: string;
14
+ ref?: string;
15
+ }): JoinResult;
16
+ export declare function runJoin(args: string[]): number;
17
+ export declare function runSettings(args: string[]): number;
@@ -0,0 +1,91 @@
1
+ // `nikos join` and `nikos settings`: link this repo to the plan above it, and see or change the
2
+ // settings it inherits. Joining is one line in planning/upstream.yaml plus a sync; nothing in
3
+ // the repo moves, so a team that started on its own keeps everything it has.
4
+ import { join } from "node:path";
5
+ import { parseDocument } from "yaml";
6
+ import { REPO_PATH } from "../lib/bets.js";
7
+ import { formatSettings, resolveSettings, setSetting } from "../lib/settings.js";
8
+ import { store } from "../lib/store.js";
9
+ import { sync, upstreamEntries } from "../planning/upstream.js";
10
+ /** Add an upstream plan to planning/upstream.yaml (if it isn't there), sync, and resolve the settings. */
11
+ export function joinPlan(root, input) {
12
+ if (!REPO_PATH.test(input.repo))
13
+ throw new Error(`'${input.repo}' is not a repo path (owner/repo, or group/subgroup/project on GitLab)`);
14
+ const path = join(root, "planning", "upstream.yaml");
15
+ const added = !upstreamEntries(root).some((e) => e.repo === input.repo);
16
+ if (added) {
17
+ const before = store().exists(path) ? store().read(path) : undefined;
18
+ const doc = parseDocument(before ?? "# Plans in other repos this one works toward. nikos planning sync reads them.\n[]\n");
19
+ const entry = { repo: input.repo, ...(input.ref ? { ref: input.ref } : {}), ...(input.host ? { host: input.host } : {}), ...(input.url ? { url: input.url } : {}) };
20
+ if (!doc.contents || !("items" in doc.contents))
21
+ doc.contents = doc.createNode([]);
22
+ doc.contents.flow = false;
23
+ doc.contents.items.push(doc.createNode(entry));
24
+ store().write(path, doc.toString({ lineWidth: 0 }));
25
+ try {
26
+ upstreamEntries(root); // a bad host or url fails here, before anything is fetched
27
+ }
28
+ catch (err) {
29
+ store().write(path, before ?? "[]\n");
30
+ throw err;
31
+ }
32
+ }
33
+ const report = sync(root);
34
+ return { repo: input.repo, added, sync: report, settings: resolveSettings(root) };
35
+ }
36
+ export function runJoin(args) {
37
+ const flag = (n) => {
38
+ const i = args.indexOf(`--${n}`);
39
+ return i >= 0 ? args[i + 1] : undefined;
40
+ };
41
+ const repo = args.find((a, i) => !a.startsWith("--") && !(i > 0 && args[i - 1].startsWith("--")));
42
+ if (!repo) {
43
+ console.error("Usage: nikos join <owner/repo | group/subgroup/project> [--host gitlab.com] [--url <git url>] [--ref <branch>] [--dir <path>]");
44
+ return 1;
45
+ }
46
+ try {
47
+ const r = joinPlan(flag("dir") ?? process.cwd(), { repo, host: flag("host"), url: flag("url"), ref: flag("ref") });
48
+ console.log(r.added ? `Joined ${r.repo}: added to planning/upstream.yaml` : `Already joined to ${r.repo}`);
49
+ for (const s of r.sync.synced)
50
+ console.log(` ✓ ${s.repo} at ${s.commit.slice(0, 7)}${s.via ? ` (above ${s.via})` : ""}`);
51
+ for (const u of r.sync.unresolved)
52
+ console.log(` ? ${u.repo}: ${u.reason}`);
53
+ console.log(`\nSettings here now:\n${formatSettings(r.settings)}`);
54
+ return r.sync.unresolved.some((u) => u.repo === r.repo) ? 1 : 0;
55
+ }
56
+ catch (err) {
57
+ console.error(`✗ ${err.message}`);
58
+ return 1;
59
+ }
60
+ }
61
+ /** Read a value given on the command line: JSON when it parses (true, ["app","chat"]), else text; a, b, c is a list. */
62
+ const valueOf = (raw) => {
63
+ try {
64
+ return JSON.parse(raw);
65
+ }
66
+ catch {
67
+ return raw.includes(",") ? raw.split(",").map((s) => s.trim()).filter(Boolean) : raw;
68
+ }
69
+ };
70
+ export function runSettings(args) {
71
+ const i = args.indexOf("--dir");
72
+ const root = i >= 0 ? args[i + 1] : process.cwd();
73
+ const [sub = "show", key, raw] = args.filter((a, n) => a !== "--dir" && (i < 0 || n !== i + 1));
74
+ try {
75
+ if (sub === "show") {
76
+ console.log(formatSettings(resolveSettings(root)));
77
+ return 0;
78
+ }
79
+ if (sub === "set" && key && raw !== undefined) {
80
+ setSetting(root, key, valueOf(raw));
81
+ console.log(`${key}: ${JSON.stringify(resolveSettings(root)[key].value)} (this repo)`);
82
+ return 0;
83
+ }
84
+ console.error("Usage: nikos settings [show] | nikos settings set <key> <value> [--dir <path>]");
85
+ return 1;
86
+ }
87
+ catch (err) {
88
+ console.error(`✗ ${err.message}`);
89
+ return 1;
90
+ }
91
+ }
@@ -0,0 +1,65 @@
1
+ export interface MapCriterion {
2
+ id: string;
3
+ title: string;
4
+ priority: string;
5
+ }
6
+ export interface MapStory {
7
+ id: string;
8
+ title: string;
9
+ activity: string;
10
+ /** The step within the activity, or "" when the story has none. */
11
+ step: string;
12
+ status: string;
13
+ blocked: boolean;
14
+ priority: string;
15
+ effort: string;
16
+ /** The user story sentence: As a…, I want…, So that…. */
17
+ narrative: string;
18
+ criteria: MapCriterion[];
19
+ /** user_story (the default), technical or bug_risk, from a @type- tag. */
20
+ type: string;
21
+ personas: string[];
22
+ dependsOn: string[];
23
+ /** Dependencies that aren't done or approved yet. */
24
+ waitingOn: string[];
25
+ /** The feature changed after the story was done or approved. */
26
+ specChanged: boolean;
27
+ approvedBy: string;
28
+ cancelledReason: string;
29
+ }
30
+ export interface MapRelease {
31
+ number: number;
32
+ title: string;
33
+ goal: string;
34
+ bets: string[];
35
+ stories: MapStory[];
36
+ }
37
+ export interface StoryMap {
38
+ project: string;
39
+ key: string;
40
+ personas: {
41
+ id: string;
42
+ title: string;
43
+ description: string;
44
+ }[];
45
+ activities: {
46
+ id: string;
47
+ title: string;
48
+ steps: {
49
+ id: string;
50
+ title: string;
51
+ }[];
52
+ }[];
53
+ releases: MapRelease[];
54
+ /** Stories with a feature file that no release names. */
55
+ unreleased: MapStory[];
56
+ }
57
+ export declare const esc: (s: unknown) => string;
58
+ /** The story map as data: activities, releases with their bets, and every story's status. */
59
+ export declare function storyMapOf(dir: string): StoryMap;
60
+ /** The whole story map as one standalone HTML page. live adds the dashboard's filter bar and its script. */
61
+ export declare function renderMap(dir: string, opts?: {
62
+ live?: boolean;
63
+ }): string;
64
+ /** `nikos map`: write the story map as a standalone page. */
65
+ export declare function runMap(args: string[]): number;