projectkeeper 0.0.0-stage → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +56 -0
- package/LICENSE +21 -0
- package/README.md +283 -2
- package/README.zh-CN.md +285 -0
- package/THIRD_PARTY_NOTICES.md +53 -0
- package/bin/pk.mjs +6 -0
- package/bin/postinstall.mjs +5 -0
- package/bin/strip-types.mjs +28 -0
- package/package.json +68 -4
- package/patches/README.md +111 -0
- package/patches/pi-ai-0.85.0.ts +122 -0
- package/patches/pi-ai-0.87.1.ts +122 -0
- package/scripts/apply-pi-streaming-patch.ts +86 -0
- package/skills/clerk/coverage/SKILL.md +35 -0
- package/skills/clerk/cross-check/SKILL.md +118 -0
- package/skills/clerk/deepen-dispatch/SKILL.md +69 -0
- package/skills/clerk/lane-follow-up/SKILL.md +94 -0
- package/skills/clerk/lane-plan/SKILL.md +107 -0
- package/skills/clerk/lane-slot/SKILL.md +113 -0
- package/skills/clerk/lane-topic/SKILL.md +116 -0
- package/skills/clerk/orientation/SKILL.md +160 -0
- package/skills/clerk/reconcile/SKILL.md +77 -0
- package/skills/clerk/skeleton-dispatch/SKILL.md +43 -0
- package/skills/clerk/spot-check/SKILL.md +52 -0
- package/skills/clerk/synthesis/SKILL.md +65 -0
- package/skills/table-follow-up/SKILL.md +42 -0
- package/src/cli.ts +319 -0
- package/src/codemap/README.md +13 -0
- package/src/codemap/engines.ts +102 -0
- package/src/codemap/facts.ts +504 -0
- package/src/context/agent-entry.ts +232 -0
- package/src/context/ask.ts +97 -0
- package/src/context/assemble.ts +1058 -0
- package/src/context/code-source.ts +103 -0
- package/src/context/k-briefs.ts +114 -0
- package/src/context/lineage.ts +100 -0
- package/src/context/object-brief.ts +300 -0
- package/src/context/owner-words.ts +191 -0
- package/src/context/pack-fixture.ts +342 -0
- package/src/context/project-rules.ts +160 -0
- package/src/intake/intake.ts +392 -0
- package/src/intake/material-rules.ts +166 -0
- package/src/intake/removal.ts +316 -0
- package/src/intake/skipped.ts +72 -0
- package/src/keeper/adjustment.ts +565 -0
- package/src/keeper/bounds/boundary.ts +509 -0
- package/src/keeper/bounds/command.ts +1665 -0
- package/src/keeper/bounds/paths.ts +164 -0
- package/src/keeper/bounds/reads.ts +718 -0
- package/src/keeper/bounds/shell-write-guard.ts +521 -0
- package/src/keeper/capabilities.ts +136 -0
- package/src/keeper/clerk-steps.ts +151 -0
- package/src/keeper/clerk-tools.ts +1424 -0
- package/src/keeper/conversation.ts +253 -0
- package/src/keeper/evidence.ts +662 -0
- package/src/keeper/fake-provider.ts +132 -0
- package/src/keeper/held.ts +15 -0
- package/src/keeper/history-tools.ts +163 -0
- package/src/keeper/jobs/answering.ts +53 -0
- package/src/keeper/key-check.ts +105 -0
- package/src/keeper/ledger-tools.ts +295 -0
- package/src/keeper/link-check.ts +185 -0
- package/src/keeper/merge.ts +260 -0
- package/src/keeper/note-origin.ts +56 -0
- package/src/keeper/numbers-check.ts +102 -0
- package/src/keeper/open-in-pi.ts +57 -0
- package/src/keeper/organize/absence.ts +266 -0
- package/src/keeper/organize/carried-on.ts +214 -0
- package/src/keeper/organize/clerk-coverage.ts +293 -0
- package/src/keeper/organize/clerk-prompts.ts +132 -0
- package/src/keeper/organize/clerk.ts +1395 -0
- package/src/keeper/organize/coverage-gate.ts +19 -0
- package/src/keeper/organize/coverage-tools.ts +765 -0
- package/src/keeper/organize/entries.ts +153 -0
- package/src/keeper/organize/entry-gate.ts +289 -0
- package/src/keeper/organize/fill-tools.ts +718 -0
- package/src/keeper/organize/follow-up.ts +280 -0
- package/src/keeper/organize/generation-check.ts +504 -0
- package/src/keeper/organize/graph.ts +230 -0
- package/src/keeper/organize/lane-tools.ts +289 -0
- package/src/keeper/organize/looked-tools.ts +54 -0
- package/src/keeper/organize/materials.ts +243 -0
- package/src/keeper/organize/owner-lines.ts +493 -0
- package/src/keeper/organize/place-tools.ts +216 -0
- package/src/keeper/organize/placement-inference.ts +1045 -0
- package/src/keeper/organize/placement-time.ts +320 -0
- package/src/keeper/organize/placing.ts +821 -0
- package/src/keeper/organize/positioning.ts +33 -0
- package/src/keeper/organize/readiness.ts +47 -0
- package/src/keeper/organize/reading.ts +401 -0
- package/src/keeper/organize/round-blocks.ts +65 -0
- package/src/keeper/organize/round-news.ts +164 -0
- package/src/keeper/organize/round-open.ts +586 -0
- package/src/keeper/organize/schedule-state.ts +39 -0
- package/src/keeper/organize/schedule.ts +137 -0
- package/src/keeper/organize/service.ts +409 -0
- package/src/keeper/organize/skills.ts +116 -0
- package/src/keeper/organize/slots.ts +85 -0
- package/src/keeper/organize/stage-gate.ts +175 -0
- package/src/keeper/organize/stage-tools.ts +471 -0
- package/src/keeper/organize/standing-notes.ts +141 -0
- package/src/keeper/organize/table-follow-up.ts +330 -0
- package/src/keeper/organize/takeover-clerk.ts +832 -0
- package/src/keeper/organize/takeover-state.ts +103 -0
- package/src/keeper/organize/takeover.ts +216 -0
- package/src/keeper/organize/update-pending.ts +535 -0
- package/src/keeper/organize/workbench-placement.ts +92 -0
- package/src/keeper/own-keys.ts +114 -0
- package/src/keeper/owner-text.ts +165 -0
- package/src/keeper/project-folder.ts +334 -0
- package/src/keeper/prompts.ts +101 -0
- package/src/keeper/references.ts +177 -0
- package/src/keeper/relook/compose.ts +120 -0
- package/src/keeper/relook/prompts.ts +36 -0
- package/src/keeper/roles.ts +259 -0
- package/src/keeper/round-tools.ts +227 -0
- package/src/keeper/route.ts +148 -0
- package/src/keeper/rules.ts +97 -0
- package/src/keeper/runtime.ts +1858 -0
- package/src/keeper/scope-tools.ts +194 -0
- package/src/keeper/sendback-text.ts +83 -0
- package/src/keeper/step-timing.ts +207 -0
- package/src/keeper/testing/clerk-script.ts +212 -0
- package/src/keeper/testing/streaming-arguments.ts +57 -0
- package/src/keeper/tools.ts +2340 -0
- package/src/keeper/utterance-tools.ts +64 -0
- package/src/keeper/verbatim.ts +397 -0
- package/src/ledger/adapters.ts +186 -0
- package/src/ledger/arrangements.ts +257 -0
- package/src/ledger/blame.ts +58 -0
- package/src/ledger/code-engine-child.ts +69 -0
- package/src/ledger/code-engine.ts +548 -0
- package/src/ledger/code.ts +607 -0
- package/src/ledger/docs.ts +294 -0
- package/src/ledger/git-read.ts +321 -0
- package/src/ledger/index.ts +1850 -0
- package/src/ledger/item-range.ts +16 -0
- package/src/ledger/lines.ts +328 -0
- package/src/ledger/numbering.ts +269 -0
- package/src/ledger/ranges.ts +74 -0
- package/src/ledger/rebuild-worker.ts +15 -0
- package/src/ledger/rebuild.ts +387 -0
- package/src/ledger/repo-scan.ts +181 -0
- package/src/ledger/schema.ts +450 -0
- package/src/ledger/sessions.ts +134 -0
- package/src/ledger/text-scan.ts +466 -0
- package/src/ledger/time.ts +185 -0
- package/src/ledger/ts-symbols.ts +180 -0
- package/src/ledger/views.ts +377 -0
- package/src/mcp/server.ts +127 -0
- package/src/mcp/tools.ts +187 -0
- package/src/model/ids.ts +23 -0
- package/src/model/k-types.ts +912 -0
- package/src/model/time.ts +85 -0
- package/src/model/types.ts +1382 -0
- package/src/model/views-k.ts +707 -0
- package/src/model/vocab.ts +269 -0
- package/src/process/analysis.ts +79 -0
- package/src/process/breakpoint-candidates.ts +383 -0
- package/src/process/breakpoints.ts +746 -0
- package/src/process/claim-check.ts +295 -0
- package/src/process/delivery-links.ts +52 -0
- package/src/process/facts.ts +487 -0
- package/src/process/generations.ts +138 -0
- package/src/process/index.ts +243 -0
- package/src/process/integration.ts +128 -0
- package/src/process/placement.ts +107 -0
- package/src/process/plan.ts +276 -0
- package/src/process/sendbacks.ts +244 -0
- package/src/process/text.ts +185 -0
- package/src/process/units.ts +543 -0
- package/src/process/unnumbered.ts +431 -0
- package/src/process/work.ts +884 -0
- package/src/scope/candidates.ts +86 -0
- package/src/scope/decisions.ts +229 -0
- package/src/scope/discover.ts +741 -0
- package/src/scope/ignore.ts +126 -0
- package/src/scope/ignored-place.ts +93 -0
- package/src/scope/skip.ts +161 -0
- package/src/scope/toolchain.ts +180 -0
- package/src/scope/worktree.ts +187 -0
- package/src/server/api.ts +549 -0
- package/src/server/app.ts +595 -0
- package/src/server/drafts-api.ts +170 -0
- package/src/server/folders-api.ts +243 -0
- package/src/server/graph-view.ts +639 -0
- package/src/server/http.ts +224 -0
- package/src/server/k-agent-api.ts +56 -0
- package/src/server/k-rounds-d99.ts +91 -0
- package/src/server/k-views.ts +405 -0
- package/src/server/keeper-page.ts +446 -0
- package/src/server/keys-api.ts +239 -0
- package/src/server/project-folder-api.ts +104 -0
- package/src/server/vendor.ts +14 -0
- package/src/server/workbench-content.ts +561 -0
- package/src/sources/anchor.ts +129 -0
- package/src/sources/files.ts +267 -0
- package/src/sources/gitobs.ts +41 -0
- package/src/sources/history.ts +231 -0
- package/src/sources/scan-worker.ts +10 -0
- package/src/sources/sessions/locate.ts +292 -0
- package/src/sources/sessions/read.ts +346 -0
- package/src/sources/sessions/scope.ts +42 -0
- package/src/sources/sessions/utterances.ts +223 -0
- package/src/sources/watch.ts +215 -0
- package/src/store/json-file.ts +50 -0
- package/src/store/paths.ts +39 -0
- package/src/store/project-store.ts +295 -0
- package/src/store/versions.ts +172 -0
- package/src/store/workspace.ts +216 -0
- package/src/table/check.ts +333 -0
- package/src/table/follow-up-tools.ts +30 -0
- package/src/table/follow-up.ts +344 -0
- package/src/table/import.ts +610 -0
- package/src/table/project-home.ts +61 -0
- package/src/table/sessions.ts +168 -0
- package/src/table/takeover.ts +483 -0
- package/src/util/git-check.ts +29 -0
- package/src/util/git.ts +411 -0
- package/src/util/own-ts.ts +15 -0
- package/src/util/paths.ts +225 -0
- package/template/FILLING-IN.md +148 -0
- package/template/examples/papertrail.json +747 -0
- package/template/projectkeeper.schema.json +1001 -0
- package/ui/app.js +1135 -0
- package/ui/failures.js +82 -0
- package/ui/flyout.js +120 -0
- package/ui/folder-chooser.js +264 -0
- package/ui/glossary.js +156 -0
- package/ui/graph-fit.js +157 -0
- package/ui/graph-material.js +61 -0
- package/ui/graph-palette.js +109 -0
- package/ui/graph-tools.js +62 -0
- package/ui/graph.js +1712 -0
- package/ui/index.html +31 -0
- package/ui/k/code-view.js +230 -0
- package/ui/k/draft-view.js +93 -0
- package/ui/k/k-views.css +224 -0
- package/ui/k/round-tree.js +450 -0
- package/ui/k/scope-k.js +159 -0
- package/ui/k-data.js +118 -0
- package/ui/k-fold.js +106 -0
- package/ui/k-process.css +302 -0
- package/ui/k-process.js +934 -0
- package/ui/keeper-activity.js +175 -0
- package/ui/keeper-view.js +801 -0
- package/ui/keeper.css +101 -0
- package/ui/key-order.js +62 -0
- package/ui/list-fold.js +67 -0
- package/ui/markdown.d.ts +21 -0
- package/ui/markdown.js +472 -0
- package/ui/placement.d.ts +92 -0
- package/ui/placement.js +445 -0
- package/ui/popover-place.d.ts +16 -0
- package/ui/popover-place.js +89 -0
- package/ui/popover.js +198 -0
- package/ui/project-folder.js +37 -0
- package/ui/shell-state.js +40 -0
- package/ui/strip-rows.js +171 -0
- package/ui/styles.css +492 -0
- package/ui/theme-pick.js +36 -0
- package/ui/theme.js +74 -0
- package/ui/themes/LICENSE.md +13 -0
- package/ui/themes/README.md +44 -0
- package/ui/themes/TOKENS.md +291 -0
- package/ui/themes/a1/board.css +1557 -0
- package/ui/themes/a1/pk.css +392 -0
- package/ui/themes/b1/board.css +1163 -0
- package/ui/themes/b1/pk.css +218 -0
- package/ui/themes/d2/board.css +518 -0
- package/ui/themes/d2/pk.css +243 -0
- package/ui/themes/guard.css +73 -0
- package/ui/themes/i1/board.css +776 -0
- package/ui/themes/i1/pk.css +223 -0
- package/ui/themes/manifest.json +13 -0
- package/ui/views.js +2796 -0
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Saved versions of the picture, for `Compare` (Spec §6.3, §6.5; owner D45). Two points in time can only be
|
|
3
|
+
* compared if both were kept, so the picture is saved at the moments the owner would want to compare from: each
|
|
4
|
+
* time they open the project, and at the end of each round of organizing.
|
|
5
|
+
*
|
|
6
|
+
* A version holds what a comparison needs — the objects, their state, where they hang, the relations and their
|
|
7
|
+
* assessments — not the whole asset set. On this project that is about 59 KB, 11 KB gzipped, so a year of daily
|
|
8
|
+
* versions is a few megabytes.
|
|
9
|
+
*/
|
|
10
|
+
import { createHash } from 'node:crypto';
|
|
11
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
12
|
+
import { gunzipSync, gzipSync } from 'node:zlib';
|
|
13
|
+
import { join } from 'node:path';
|
|
14
|
+
import type { ProjectStore } from './project-store.ts';
|
|
15
|
+
|
|
16
|
+
export type VersionReason = 'Opened' | 'Organized' | 'Asked';
|
|
17
|
+
|
|
18
|
+
export interface VersionNode {
|
|
19
|
+
readonly id: string;
|
|
20
|
+
readonly category: string;
|
|
21
|
+
readonly label: string;
|
|
22
|
+
readonly validity: string;
|
|
23
|
+
readonly progress: string | null;
|
|
24
|
+
readonly acceptance: string;
|
|
25
|
+
readonly areaId: string | null;
|
|
26
|
+
readonly parentId: string | null;
|
|
27
|
+
/** A digest of the object's own text, so a rewrite shows up as a content change without keeping the text twice. */
|
|
28
|
+
readonly text: string;
|
|
29
|
+
}
|
|
30
|
+
export interface VersionRelation {
|
|
31
|
+
readonly id: string;
|
|
32
|
+
readonly type: string;
|
|
33
|
+
readonly from: string;
|
|
34
|
+
readonly to: string;
|
|
35
|
+
readonly assessment: string;
|
|
36
|
+
}
|
|
37
|
+
export interface Version {
|
|
38
|
+
readonly id: string;
|
|
39
|
+
readonly at: string;
|
|
40
|
+
readonly reason: VersionReason;
|
|
41
|
+
readonly nodes: readonly VersionNode[];
|
|
42
|
+
readonly relations: readonly VersionRelation[];
|
|
43
|
+
}
|
|
44
|
+
export interface VersionInfo { readonly id: string; readonly at: string; readonly reason: VersionReason; readonly nodes: number; readonly relations: number; readonly bytes: number }
|
|
45
|
+
|
|
46
|
+
const digest = (s: string): string => createHash('sha1').update(s).digest('hex').slice(0, 12);
|
|
47
|
+
const versionsDir = (projectDir: string) => join(projectDir, 'versions');
|
|
48
|
+
|
|
49
|
+
/** The current picture, in the shape a comparison reads. */
|
|
50
|
+
export function snapshot(store: ProjectStore, reason: VersionReason, at = new Date().toISOString()): Version {
|
|
51
|
+
const textOf = (n: { refKind: string; refId: string }): string => {
|
|
52
|
+
if (n.refKind === 'reference') return store.reference.get(n.refId)?.text ?? '';
|
|
53
|
+
if (n.refKind === 'thread') { const t = store.threads.get(n.refId); return t ? `${t.doing}\0${t.results}\0${t.unresolved}\0${t.doneMeans ?? ''}` : ''; }
|
|
54
|
+
if (n.refKind === 'change') return store.changes.get(n.refId)?.summary ?? '';
|
|
55
|
+
return '';
|
|
56
|
+
};
|
|
57
|
+
return {
|
|
58
|
+
id: at.replace(/[:.]/g, '-'),
|
|
59
|
+
at,
|
|
60
|
+
reason,
|
|
61
|
+
nodes: store.nodes.all().map((n) => ({
|
|
62
|
+
id: n.id, category: n.category, label: n.label, validity: n.validity, progress: n.progress ?? null,
|
|
63
|
+
acceptance: (n as { acceptance?: string }).acceptance ?? '', areaId: n.areaId ?? null, parentId: n.parentWorkId ?? null,
|
|
64
|
+
text: digest(textOf(n)),
|
|
65
|
+
})),
|
|
66
|
+
relations: store.relations.all().map((r) => ({ id: r.id, type: r.type, from: r.from, to: r.to, assessment: r.assessment })),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Save a version unless the picture is identical to the newest one; returns what was saved, or null. */
|
|
71
|
+
export function saveVersion(projectDir: string, store: ProjectStore, reason: VersionReason, keep = 400): Version | null {
|
|
72
|
+
const dir = versionsDir(projectDir);
|
|
73
|
+
mkdirSync(dir, { recursive: true });
|
|
74
|
+
const version = snapshot(store, reason);
|
|
75
|
+
const list = listVersions(projectDir);
|
|
76
|
+
const newest = list[list.length - 1];
|
|
77
|
+
if (newest) {
|
|
78
|
+
const previous = readVersion(projectDir, newest.id);
|
|
79
|
+
if (previous && sameShape(previous, version)) return null;
|
|
80
|
+
}
|
|
81
|
+
writeFileSync(join(dir, `${version.id}__${reason}.json.gz`), gzipSync(Buffer.from(JSON.stringify(version), 'utf8')));
|
|
82
|
+
const all = listVersions(projectDir);
|
|
83
|
+
for (const old of all.slice(0, Math.max(0, all.length - keep))) rmSync(join(dir, fileOf(old.id, old.reason)), { force: true });
|
|
84
|
+
return version;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const fileOf = (id: string, reason: string) => `${id}__${reason}.json.gz`;
|
|
88
|
+
|
|
89
|
+
function sameShape(a: Version, b: Version): boolean {
|
|
90
|
+
if (a.nodes.length !== b.nodes.length || a.relations.length !== b.relations.length) return false;
|
|
91
|
+
const key = (v: Version) => JSON.stringify([v.nodes.map((n) => [n.id, n.validity, n.progress, n.acceptance, n.areaId, n.parentId, n.text, n.label]).sort(), v.relations.map((r) => [r.id, r.assessment, r.from, r.to]).sort()]);
|
|
92
|
+
return key(a) === key(b);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function listVersions(projectDir: string): VersionInfo[] {
|
|
96
|
+
const dir = versionsDir(projectDir);
|
|
97
|
+
if (!existsSync(dir)) return [];
|
|
98
|
+
const out: VersionInfo[] = [];
|
|
99
|
+
for (const file of readdirSync(dir)) {
|
|
100
|
+
const m = /^(.+)__(Opened|Organized|Asked)\.json\.gz$/.exec(file);
|
|
101
|
+
if (!m) continue;
|
|
102
|
+
const raw = readFileSync(join(dir, file));
|
|
103
|
+
let v: Version | null = null;
|
|
104
|
+
try { v = JSON.parse(gunzipSync(raw).toString('utf8')) as Version; } catch { continue; }
|
|
105
|
+
out.push({ id: m[1]!, at: v.at, reason: m[2] as VersionReason, nodes: v.nodes.length, relations: v.relations.length, bytes: raw.length });
|
|
106
|
+
}
|
|
107
|
+
return out.sort((a, b) => a.at.localeCompare(b.at));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function readVersion(projectDir: string, id: string): Version | null {
|
|
111
|
+
const dir = versionsDir(projectDir);
|
|
112
|
+
if (!existsSync(dir)) return null;
|
|
113
|
+
const file = readdirSync(dir).find((f) => f.startsWith(`${id}__`));
|
|
114
|
+
if (!file) return null;
|
|
115
|
+
try { return JSON.parse(gunzipSync(readFileSync(join(dir, file))).toString('utf8')) as Version; } catch { return null; }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export type DiffKind = 'Added' | 'Removed' | 'Content changed' | 'Regrouped' | 'Relinked';
|
|
119
|
+
export interface DiffItem {
|
|
120
|
+
readonly kind: DiffKind;
|
|
121
|
+
readonly id: string;
|
|
122
|
+
readonly label: string;
|
|
123
|
+
readonly category: string;
|
|
124
|
+
/** What changed, in the words of the fields that differ; empty for Added and Removed. */
|
|
125
|
+
readonly detail: string;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The five kinds of difference between two versions (Spec §6.3):
|
|
130
|
+
* `Added` and `Removed` are objects; `Content changed` is an object whose own text, state, progress or acceptance
|
|
131
|
+
* moved; `Regrouped` is an object that hangs somewhere else now; `Relinked` is an object whose relations changed.
|
|
132
|
+
* An object can only be counted once, in the first kind that applies, so the counts add up to the objects touched.
|
|
133
|
+
*/
|
|
134
|
+
export function diffVersions(before: Version, after: Version): { items: DiffItem[]; counts: Record<DiffKind, number> } {
|
|
135
|
+
const a = new Map(before.nodes.map((n) => [n.id, n]));
|
|
136
|
+
const b = new Map(after.nodes.map((n) => [n.id, n]));
|
|
137
|
+
const relKey = (r: VersionRelation) => `${r.type}:${r.from}>${r.to}`;
|
|
138
|
+
const relsOf = (v: Version) => {
|
|
139
|
+
const m = new Map<string, Set<string>>();
|
|
140
|
+
for (const r of v.relations) for (const end of [r.from, r.to]) { if (!m.has(end)) m.set(end, new Set()); m.get(end)!.add(`${relKey(r)}#${r.assessment}`); }
|
|
141
|
+
return m;
|
|
142
|
+
};
|
|
143
|
+
const ra = relsOf(before), rb = relsOf(after);
|
|
144
|
+
const items: DiffItem[] = [];
|
|
145
|
+
for (const [id, n] of b) if (!a.has(id)) items.push({ kind: 'Added', id, label: n.label, category: n.category, detail: '' });
|
|
146
|
+
for (const [id, n] of a) if (!b.has(id)) items.push({ kind: 'Removed', id, label: n.label, category: n.category, detail: '' });
|
|
147
|
+
for (const [id, after1] of b) {
|
|
148
|
+
const before1 = a.get(id);
|
|
149
|
+
if (!before1) continue;
|
|
150
|
+
const changed: string[] = [];
|
|
151
|
+
if (before1.validity !== after1.validity) changed.push(`validity ${before1.validity} → ${after1.validity}`);
|
|
152
|
+
if (before1.progress !== after1.progress) changed.push(`progress ${before1.progress ?? '—'} → ${after1.progress ?? '—'}`);
|
|
153
|
+
if (before1.acceptance !== after1.acceptance) changed.push(`acceptance ${before1.acceptance || '—'} → ${after1.acceptance || '—'}`);
|
|
154
|
+
if (before1.label !== after1.label) changed.push('name rewritten');
|
|
155
|
+
if (before1.text !== after1.text) changed.push('text rewritten');
|
|
156
|
+
if (changed.length > 0) { items.push({ kind: 'Content changed', id, label: after1.label, category: after1.category, detail: changed.join('; ') }); continue; }
|
|
157
|
+
if (before1.areaId !== after1.areaId || before1.parentId !== after1.parentId) {
|
|
158
|
+
const name = (x: string | null) => (x ? b.get(x)?.label ?? a.get(x)?.label ?? x : 'nothing');
|
|
159
|
+
items.push({ kind: 'Regrouped', id, label: after1.label, category: after1.category, detail: `hangs from ${name(before1.areaId ?? before1.parentId)} → ${name(after1.areaId ?? after1.parentId)}` });
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
const setA = ra.get(id) ?? new Set<string>(), setB = rb.get(id) ?? new Set<string>();
|
|
163
|
+
const gained = [...setB].filter((x) => !setA.has(x)).length;
|
|
164
|
+
const lost = [...setA].filter((x) => !setB.has(x)).length;
|
|
165
|
+
if (gained || lost) items.push({ kind: 'Relinked', id, label: after1.label, category: after1.category, detail: `${gained} relation${gained === 1 ? '' : 's'} gained, ${lost} lost` });
|
|
166
|
+
}
|
|
167
|
+
const counts = { 'Added': 0, 'Removed': 0, 'Content changed': 0, 'Regrouped': 0, 'Relinked': 0 } as Record<DiffKind, number>;
|
|
168
|
+
for (const i of items) counts[i.kind]++;
|
|
169
|
+
const order: DiffKind[] = ['Added', 'Content changed', 'Regrouped', 'Relinked', 'Removed'];
|
|
170
|
+
items.sort((x, y) => order.indexOf(x.kind) - order.indexOf(y.kind) || x.label.localeCompare(y.label));
|
|
171
|
+
return { items, counts };
|
|
172
|
+
}
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The workspace: which projects the owner added, plus settings that are not project assets
|
|
3
|
+
* (port, chosen model). Lives at `~/.projectkeeper/workspace.json`.
|
|
4
|
+
*/
|
|
5
|
+
import type { RoundStepKind } from '../model/k-types.ts';
|
|
6
|
+
import { createHash } from 'node:crypto';
|
|
7
|
+
import { EventEmitter } from 'node:events';
|
|
8
|
+
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
9
|
+
import type { Project, ScopeItem, ScopeQuestion } from '../model/types.ts';
|
|
10
|
+
import { slug } from '../model/ids.ts';
|
|
11
|
+
import { canonicalPath, expandHome } from '../util/paths.ts';
|
|
12
|
+
import { writeJsonAtomic } from './json-file.ts';
|
|
13
|
+
import { ensureHome, projectKeeperHome, workspaceFile } from './paths.ts';
|
|
14
|
+
|
|
15
|
+
export interface ModelChoice {
|
|
16
|
+
readonly provider: string;
|
|
17
|
+
readonly id: string;
|
|
18
|
+
readonly thinking: string | null;
|
|
19
|
+
}
|
|
20
|
+
/** Another key. It runs under a provider id of its own, so it has its own lanes and its own quota window; the key itself
|
|
21
|
+
* stays in the named environment variable and is read there when a request is sent. A key for a provider pi already
|
|
22
|
+
* knows names it in `like`; a key for a provider pi does not know (owner 2026-09-22: Xiaomi MiMo) brings its own
|
|
23
|
+
* endpoint, API and models instead. */
|
|
24
|
+
export interface ExtraKey {
|
|
25
|
+
readonly provider: string; // the id this key runs under, e.g. `zai-coding-cn-team`
|
|
26
|
+
readonly like?: string; // the provider whose endpoint and models it uses, e.g. `zai-coding-cn`
|
|
27
|
+
readonly apiKeyEnv: string; // the environment variable that holds the key
|
|
28
|
+
readonly name?: string;
|
|
29
|
+
readonly baseUrl?: string; // with `api` and `models`: a provider of its own, e.g. `https://api.xiaomimimo.com/v1`
|
|
30
|
+
readonly api?: string; // pi's API family for it, e.g. `openai-completions`
|
|
31
|
+
readonly models?: readonly ExtraKeyModel[];
|
|
32
|
+
}
|
|
33
|
+
/** One model of a provider an extra key brings itself; the fields are the ones pi's providers declare. */
|
|
34
|
+
export interface ExtraKeyModel {
|
|
35
|
+
readonly id: string;
|
|
36
|
+
readonly name?: string;
|
|
37
|
+
readonly reasoning?: boolean;
|
|
38
|
+
readonly contextWindow: number;
|
|
39
|
+
readonly maxTokens: number;
|
|
40
|
+
readonly input?: readonly ('text' | 'image')[];
|
|
41
|
+
readonly cost?: { readonly input?: number; readonly output?: number; readonly cacheRead?: number; readonly cacheWrite?: number };
|
|
42
|
+
readonly thinkingLevelMap?: Readonly<Record<string, string | null>>;
|
|
43
|
+
readonly compat?: Readonly<Record<string, unknown>>; // e.g. { thinkingFormat: 'deepseek', maxTokensField: 'max_tokens' }
|
|
44
|
+
}
|
|
45
|
+
export interface WorkspaceSettings {
|
|
46
|
+
readonly port: number;
|
|
47
|
+
readonly model: ModelChoice | null;
|
|
48
|
+
readonly modelBackups: readonly ModelChoice[]; // used in order when the active provider reports its quota exhausted; the switch is visible, never silent
|
|
49
|
+
readonly keeperAgent: 'pi';
|
|
50
|
+
readonly organizingLanesPerKey?: number; // automatic organizing jobs per key at once (the chosen model and each backup are keys); trial setting 2026-09-17
|
|
51
|
+
readonly extraKeys?: readonly ExtraKey[]; // more keys for known providers (owner 2026-09-18: a third Zhipu Coding Plan key); name them in model or modelBackups to use them
|
|
52
|
+
/** Automatic jobs at once on one key, set for that key (§6.10 每把 key 同时跑几路, D105); a key not listed runs `organizingLanesPerKey`. */
|
|
53
|
+
readonly keyLanes?: Readonly<Record<string, number>>;
|
|
54
|
+
readonly watchProjects?: boolean; // false for a controlled trial: the home works through what it holds and takes in no new changes, so runs started from one snapshot see the same input (owner 2026-09-18)
|
|
55
|
+
// From the follow-up experiment's arm B (owner 2026-09-18); its other settings went once every round was one main job (D59).
|
|
56
|
+
readonly routes?: Readonly<Record<string, string>>; // job route or kind → model id on the same key's provider, e.g. { "frame": "glm-5.3", "relook": "glm-5.3" }
|
|
57
|
+
/**
|
|
58
|
+
* The model and thinking level of each step of a clerk-method round (Spec §3.3, CKC-03 AC-29, CKC-23 AC-11), on the
|
|
59
|
+
* provider of the key a lane runs on. A step left out runs on the key's own model and thinking level. The baseline of
|
|
60
|
+
* increment K runs every step on glm-5.3 at the highest thinking (D87, D90).
|
|
61
|
+
*/
|
|
62
|
+
readonly steps?: Readonly<Partial<Record<RoundStepKind, { readonly model?: string; readonly thinking?: string }>>>;
|
|
63
|
+
}
|
|
64
|
+
interface WorkspaceFile {
|
|
65
|
+
readonly version: 1;
|
|
66
|
+
readonly projects: Project[];
|
|
67
|
+
readonly settings: WorkspaceSettings;
|
|
68
|
+
readonly lastProjectId: string | null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const DEFAULT_SETTINGS: WorkspaceSettings = { port: 4870, model: null, modelBackups: [], keeperAgent: 'pi' };
|
|
72
|
+
|
|
73
|
+
/** The settings file is there and cannot be read as a workspace. The message names the file and says what to do. */
|
|
74
|
+
export class WorkspaceFileError extends Error {
|
|
75
|
+
readonly file: string;
|
|
76
|
+
constructor(file: string, what: string) {
|
|
77
|
+
super(`Cannot read the settings file ${file}: ${what}. Correct it and start again, or move the file away to start with an empty workspace (the projects' assets under projects/ stay where they are). Nothing was changed.`);
|
|
78
|
+
this.name = 'WorkspaceFileError';
|
|
79
|
+
this.file = file;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Read the settings file. Every key it lacks takes its default — `projects` an empty list, `settings` the defaults,
|
|
87
|
+
* a project's lists empty — so a file written by hand or by an earlier version loads. What cannot be given a default
|
|
88
|
+
* (text that is not JSON, a project without an id or without locations) is said, with the file's name, and the file is
|
|
89
|
+
* left as it is: starting with an empty workspace instead would write that over the owner's projects on the next save.
|
|
90
|
+
*/
|
|
91
|
+
function readWorkspaceFile(file: string): WorkspaceFile {
|
|
92
|
+
if (!existsSync(file)) return { version: 1, projects: [], settings: DEFAULT_SETTINGS, lastProjectId: null };
|
|
93
|
+
let raw: unknown;
|
|
94
|
+
try { raw = JSON.parse(readFileSync(file, 'utf8').replace(/^/, '')); }
|
|
95
|
+
catch (error) { throw new WorkspaceFileError(file, `it is not valid JSON (${(error as Error).message})`); }
|
|
96
|
+
if (!isRecord(raw)) throw new WorkspaceFileError(file, 'it does not hold a JSON object ({ "projects": […], "settings": {…} })');
|
|
97
|
+
if (raw.projects !== undefined && !Array.isArray(raw.projects)) throw new WorkspaceFileError(file, '"projects" is not a list');
|
|
98
|
+
if (raw.settings !== undefined && raw.settings !== null && !isRecord(raw.settings)) throw new WorkspaceFileError(file, '"settings" is not an object');
|
|
99
|
+
const projects = ((raw.projects as unknown[] | undefined) ?? []).map((entry, index): Project => {
|
|
100
|
+
const where = `project ${index + 1} of "projects"`;
|
|
101
|
+
if (!isRecord(entry)) throw new WorkspaceFileError(file, `${where} is not an object`);
|
|
102
|
+
if (typeof entry.id !== 'string' || !entry.id.trim()) throw new WorkspaceFileError(file, `${where} has no "id"`);
|
|
103
|
+
if (!Array.isArray(entry.locations) || entry.locations.length === 0 || entry.locations.some((l) => typeof l !== 'string' || !l.trim())) {
|
|
104
|
+
throw new WorkspaceFileError(file, `${where} (${entry.id}) has no "locations": a list with at least one directory`);
|
|
105
|
+
}
|
|
106
|
+
const list = (key: string): unknown[] => (Array.isArray(entry[key]) ? entry[key] as unknown[] : []);
|
|
107
|
+
return {
|
|
108
|
+
...(entry as unknown as Project),
|
|
109
|
+
name: typeof entry.name === 'string' && entry.name.trim() ? entry.name : entry.id,
|
|
110
|
+
// One spelling of each location, the file system's own: the one a project is added in (see `add`).
|
|
111
|
+
locations: (entry.locations as string[]).map((l) => canonicalPath(l)),
|
|
112
|
+
scope: list('scope') as ScopeItem[], scopeQuestions: list('scopeQuestions') as ScopeQuestion[],
|
|
113
|
+
keeperFiles: list('keeperFiles') as Project['keeperFiles'], roles: list('roles') as string[],
|
|
114
|
+
language: typeof entry.language === 'string' && entry.language ? entry.language : 'en',
|
|
115
|
+
organizingPaused: entry.organizingPaused === true,
|
|
116
|
+
createdAt: typeof entry.createdAt === 'string' && entry.createdAt ? entry.createdAt : statSync(file).mtime.toISOString(),
|
|
117
|
+
lastOpenedAt: typeof entry.lastOpenedAt === 'string' ? entry.lastOpenedAt : null,
|
|
118
|
+
lastScopedAt: typeof entry.lastScopedAt === 'string' ? entry.lastScopedAt : null,
|
|
119
|
+
};
|
|
120
|
+
});
|
|
121
|
+
const settings = { ...DEFAULT_SETTINGS, ...((raw.settings as Partial<WorkspaceSettings> | null | undefined) ?? {}) };
|
|
122
|
+
const lastProjectId = typeof raw.lastProjectId === 'string' && projects.some((p) => p.id === raw.lastProjectId) ? raw.lastProjectId : null;
|
|
123
|
+
return { version: 1, projects, settings, lastProjectId };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export class Workspace extends EventEmitter {
|
|
127
|
+
readonly home: string;
|
|
128
|
+
private data: WorkspaceFile;
|
|
129
|
+
|
|
130
|
+
private constructor(home: string, data: WorkspaceFile) {
|
|
131
|
+
super();
|
|
132
|
+
this.home = home;
|
|
133
|
+
this.data = data;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Open the workspace of a home. A settings file that is not there yet is an empty workspace. One that is there is
|
|
138
|
+
* read as it is and never replaced because of what it holds: a key it lacks (a file written by hand, or by an earlier
|
|
139
|
+
* version) takes its default, and a file that cannot be read as a workspace stops here with a message naming it.
|
|
140
|
+
*/
|
|
141
|
+
static open(home = projectKeeperHome()): Workspace {
|
|
142
|
+
const at = canonicalPath(home);
|
|
143
|
+
return new Workspace(at, readWorkspaceFile(workspaceFile(at)));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
get settings(): WorkspaceSettings { return this.data.settings; }
|
|
147
|
+
get lastProjectId(): string | null { return this.data.lastProjectId; }
|
|
148
|
+
|
|
149
|
+
list(): readonly Project[] { return this.data.projects; }
|
|
150
|
+
|
|
151
|
+
get(id: string): Project | undefined { return this.data.projects.find((p) => p.id === id); }
|
|
152
|
+
|
|
153
|
+
/** Project id: name slug plus a hash of the first location, stable across renames of the store. */
|
|
154
|
+
static idFor(name: string, firstLocation: string): string {
|
|
155
|
+
const hash = createHash('sha1').update(canonicalPath(firstLocation).toLowerCase()).digest('hex').slice(0, 6);
|
|
156
|
+
return `${slug(name)}-${hash}`;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Add a project. Its locations are kept in the file system's own spelling (`canonicalPath`), whichever way they were
|
|
161
|
+
* given — a short (8.3) name, a junction, a `subst` drive, another case: that is the spelling git reports the
|
|
162
|
+
* repository and its worktrees in, and every later comparison is made on it. A location typed with `~` is under
|
|
163
|
+
* the home directory.
|
|
164
|
+
*/
|
|
165
|
+
add(name: string, locations: readonly string[]): Project {
|
|
166
|
+
const cleaned = [...new Set(locations.map((l) => canonicalPath(expandHome(l))))];
|
|
167
|
+
if (cleaned.length === 0) throw new Error('A project needs at least one location');
|
|
168
|
+
const id = Workspace.idFor(name, cleaned[0]!);
|
|
169
|
+
if (this.get(id)) throw new Error(`Project already exists: ${id}`);
|
|
170
|
+
const now = new Date().toISOString();
|
|
171
|
+
const project: Project = {
|
|
172
|
+
id, name: name.trim(), locations: cleaned, scope: [], scopeQuestions: [], keeperFiles: [], roles: [],
|
|
173
|
+
language: 'en', organizingPaused: false, createdAt: now, lastOpenedAt: null, lastScopedAt: null,
|
|
174
|
+
};
|
|
175
|
+
this.data = { ...this.data, projects: [...this.data.projects, project], lastProjectId: id };
|
|
176
|
+
this.save();
|
|
177
|
+
this.emit('change', { kind: 'projects' });
|
|
178
|
+
return project;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
update(project: Project): Project {
|
|
182
|
+
const index = this.data.projects.findIndex((p) => p.id === project.id);
|
|
183
|
+
if (index < 0) throw new Error(`Unknown project: ${project.id}`);
|
|
184
|
+
const projects = [...this.data.projects];
|
|
185
|
+
projects[index] = project;
|
|
186
|
+
this.data = { ...this.data, projects };
|
|
187
|
+
this.save();
|
|
188
|
+
this.emit('change', { kind: 'project', id: project.id });
|
|
189
|
+
return project;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
remove(id: string): void {
|
|
193
|
+
this.data = { ...this.data, projects: this.data.projects.filter((p) => p.id !== id),
|
|
194
|
+
lastProjectId: this.data.lastProjectId === id ? null : this.data.lastProjectId };
|
|
195
|
+
this.save();
|
|
196
|
+
this.emit('change', { kind: 'projects' });
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
setLastProject(id: string | null): void {
|
|
200
|
+
if (this.data.lastProjectId === id) return;
|
|
201
|
+
this.data = { ...this.data, lastProjectId: id };
|
|
202
|
+
this.save();
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
setSettings(patch: Partial<WorkspaceSettings>): WorkspaceSettings {
|
|
206
|
+
this.data = { ...this.data, settings: { ...this.data.settings, ...patch } };
|
|
207
|
+
this.save();
|
|
208
|
+
this.emit('change', { kind: 'settings' });
|
|
209
|
+
return this.data.settings;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
private save(): void {
|
|
213
|
+
ensureHome(this.home);
|
|
214
|
+
writeJsonAtomic(workspaceFile(this.home), this.data);
|
|
215
|
+
}
|
|
216
|
+
}
|