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,333 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `pk check`: the program's side of the ProjectKeeper table (template/FILLING-IN.md, "What the program checks"). Two
|
|
3
|
+
* layers, both hard: the shape against template/projectkeeper.schema.json, then the facts — ids, references, the rules
|
|
4
|
+
* that tie a status to its fields, and every source against the project as it stood at the table's `asOf` commits.
|
|
5
|
+
* Nothing here judges what an entry says or where it was put; that is the filler's (D107). Read-only throughout.
|
|
6
|
+
*/
|
|
7
|
+
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
8
|
+
import { dirname, join, relative, resolve, sep } from 'node:path';
|
|
9
|
+
import { fileURLToPath } from 'node:url';
|
|
10
|
+
import Schema from 'typebox/schema';
|
|
11
|
+
import { git, gitShow } from '../util/git.ts';
|
|
12
|
+
|
|
13
|
+
export interface TableProblem {
|
|
14
|
+
/** Where in the table: a JSON pointer such as /work/3/sources/0. */
|
|
15
|
+
readonly at: string;
|
|
16
|
+
readonly problem: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** One message of the session material: the source a `session` entry can cite. */
|
|
20
|
+
export interface SessionMessage {
|
|
21
|
+
readonly session: string;
|
|
22
|
+
readonly message: number;
|
|
23
|
+
readonly text: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
27
|
+
export const SCHEMA_FILE = resolve(here, '..', '..', 'template', 'projectkeeper.schema.json');
|
|
28
|
+
|
|
29
|
+
type Json = Record<string, unknown>;
|
|
30
|
+
export type TableSource = { file?: string; lines?: string; quote?: string; commit?: string; repo?: string; session?: string; message?: number };
|
|
31
|
+
/** A repository of the table's `asOf`: its folder relative to the project (normalised), the commit, and its folder on disk. */
|
|
32
|
+
export type Repo = { path: string; commit: string; dir: string };
|
|
33
|
+
|
|
34
|
+
/** Whitespace does not decide whether a quote stands in its source: line breaks and runs of spaces read as one space. */
|
|
35
|
+
const an = (word: string): string => `${/^[aeiou]/i.test(word) ? 'an' : 'a'} ${word}`;
|
|
36
|
+
const squash = (text: string): string => text.replace(/\s+/g, ' ').trim();
|
|
37
|
+
|
|
38
|
+
/** Every list of the table that holds entries with ids, and the singular name its entries go by in messages. */
|
|
39
|
+
const LISTS: readonly (readonly [string, string])[] = [
|
|
40
|
+
['ownerWants', 'owner want'], ['goals', 'goal'], ['areas', 'area'], ['requirements', 'requirement'], ['designs', 'design'],
|
|
41
|
+
['decisions', 'decision'], ['plans', 'plan'], ['work', 'work item'], ['code', 'territory'], ['notes', 'note'],
|
|
42
|
+
];
|
|
43
|
+
const INTENT_LISTS = ['requirements', 'designs', 'decisions'];
|
|
44
|
+
|
|
45
|
+
export function checkShape(table: unknown): TableProblem[] {
|
|
46
|
+
const schema = JSON.parse(readFileSync(SCHEMA_FILE, 'utf8'));
|
|
47
|
+
const [ok, errors] = Schema.Errors(schema, table);
|
|
48
|
+
if (ok) return [];
|
|
49
|
+
return errors.map((e) => ({ at: e.instancePath || '/', problem: e.message ?? 'does not match the schema' }));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The facts. `projectDir` is the project folder the table's paths are relative to; `sessions` is the session material,
|
|
54
|
+
* needed only when the table cites a session.
|
|
55
|
+
*/
|
|
56
|
+
export function checkFacts(table: Json, projectDir: string, sessions: readonly SessionMessage[] | null): TableProblem[] {
|
|
57
|
+
const problems: TableProblem[] = [];
|
|
58
|
+
const add = (at: string, problem: string) => problems.push({ at, problem });
|
|
59
|
+
|
|
60
|
+
// ── ids: one namespace for the whole table ──
|
|
61
|
+
const kindOf = new Map<string, string>();
|
|
62
|
+
const product = table.product as Json;
|
|
63
|
+
if (product?.id) kindOf.set(String(product.id), 'product');
|
|
64
|
+
for (const [list, kind] of LISTS) {
|
|
65
|
+
((table[list] as Json[]) ?? []).forEach((entry, i) => {
|
|
66
|
+
const id = String(entry.id);
|
|
67
|
+
if (kindOf.has(id)) add(`/${list}/${i}/id`, `the id "${id}" is already used by ${an(kindOf.get(id)!)}`);
|
|
68
|
+
else kindOf.set(id, list === 'decisions' && entry.boundary ? 'decision' : kind);
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
const refTo = (at: string, id: unknown, kinds: readonly string[]) => {
|
|
72
|
+
if (typeof id !== 'string') return;
|
|
73
|
+
const kind = kindOf.get(id);
|
|
74
|
+
if (!kind) add(at, `"${id}" is not an id in this table`);
|
|
75
|
+
else if (!kinds.includes(kind)) add(at, `"${id}" is ${an(kind)}; this needs ${kinds.map(an).join(' or ')}`);
|
|
76
|
+
};
|
|
77
|
+
const ANY_PROJECT = ['product', 'goal', 'area', 'requirement', 'design', 'decision', 'plan', 'work item', 'territory'];
|
|
78
|
+
const INTENT = ['requirement', 'design', 'decision'];
|
|
79
|
+
|
|
80
|
+
// ── references and the rules that tie a status to its fields ──
|
|
81
|
+
const statusRules = (at: string, e: Json, sameKinds: readonly string[]) => {
|
|
82
|
+
if (e.replacedBy !== undefined) refTo(`${at}/replacedBy`, e.replacedBy, sameKinds);
|
|
83
|
+
if (e.status === 'Replaced' && e.replacedBy === undefined) add(`${at}`, 'it is Replaced, so replacedBy must name what took its place');
|
|
84
|
+
if ((e.status === 'Abandoned' || e.status === 'Deferred') && e.why === undefined) add(`${at}`, `it is ${e.status}, so why must say what happened`);
|
|
85
|
+
if (e.replacedBy !== undefined && e.replacedBy === e.id) add(`${at}/replacedBy`, 'an entry cannot replace itself');
|
|
86
|
+
};
|
|
87
|
+
((table.ownerWants as Json[]) ?? []).forEach((w, i) => {
|
|
88
|
+
const at = `/ownerWants/${i}`;
|
|
89
|
+
if (w.replacedBy !== undefined) refTo(`${at}/replacedBy`, w.replacedBy, ['owner want']);
|
|
90
|
+
if (w.status === 'Replaced' && w.replacedBy === undefined) add(at, 'it is Replaced, so replacedBy must name the want that holds now');
|
|
91
|
+
((w.reflectedIn as unknown[]) ?? []).forEach((id, j) => refTo(`${at}/reflectedIn/${j}`, id, ANY_PROJECT));
|
|
92
|
+
});
|
|
93
|
+
((table.goals as Json[]) ?? []).forEach((g, i) => statusRules(`/goals/${i}`, g, ['goal']));
|
|
94
|
+
((table.areas as Json[]) ?? []).forEach((a, i) => {
|
|
95
|
+
statusRules(`/areas/${i}`, a, ['area']);
|
|
96
|
+
((a.goals as unknown[]) ?? []).forEach((id, j) => refTo(`/areas/${i}/goals/${j}`, id, ['goal']));
|
|
97
|
+
});
|
|
98
|
+
for (const list of INTENT_LISTS) {
|
|
99
|
+
((table[list] as Json[]) ?? []).forEach((e, i) => {
|
|
100
|
+
const at = `/${list}/${i}`;
|
|
101
|
+
statusRules(at, e, INTENT);
|
|
102
|
+
const place = (e.place ?? {}) as Json;
|
|
103
|
+
if (place.area !== undefined) refTo(`${at}/place/area`, place.area, ['area']);
|
|
104
|
+
((place.areas as unknown[]) ?? []).forEach((id, j) => refTo(`${at}/place/areas/${j}`, id, ['area']));
|
|
105
|
+
if (place.plan !== undefined) refTo(`${at}/place/plan`, place.plan, ['plan']);
|
|
106
|
+
((e.partlyReplaced as Json[]) ?? []).forEach((p, j) => {
|
|
107
|
+
refTo(`${at}/partlyReplaced/${j}/by`, p.by, INTENT);
|
|
108
|
+
if (p.by === e.id) add(`${at}/partlyReplaced/${j}/by`, 'an entry cannot replace part of itself');
|
|
109
|
+
});
|
|
110
|
+
if (e.partlyReplaced !== undefined && e.status === 'Replaced') add(at, 'it is Replaced as a whole, so partlyReplaced does not apply');
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
const plans = new Map(((table.plans as Json[]) ?? []).map((p) => [String(p.id), p]));
|
|
114
|
+
((table.plans as Json[]) ?? []).forEach((p, i) => statusRules(`/plans/${i}`, p, ['plan']));
|
|
115
|
+
((table.work as Json[]) ?? []).forEach((w, i) => {
|
|
116
|
+
const at = `/work/${i}`;
|
|
117
|
+
statusRules(at, w, ['work item']);
|
|
118
|
+
refTo(`${at}/plan`, w.plan, ['plan']);
|
|
119
|
+
if (w.area !== undefined) refTo(`${at}/area`, w.area, ['area']);
|
|
120
|
+
((w.alsoAreas as unknown[]) ?? []).forEach((id, j) => refTo(`${at}/alsoAreas/${j}`, id, ['area']));
|
|
121
|
+
((w.implements as unknown[]) ?? []).forEach((id, j) => refTo(`${at}/implements/${j}`, id, INTENT));
|
|
122
|
+
((w.dependsOn as unknown[]) ?? []).forEach((id, j) => refTo(`${at}/dependsOn/${j}`, id, ['work item']));
|
|
123
|
+
const became = (w.became ?? null) as Json | null;
|
|
124
|
+
if (became?.carriedOnBy !== undefined) refTo(`${at}/became/carriedOnBy`, became.carriedOnBy, ['work item']);
|
|
125
|
+
if (became?.movedTo !== undefined) refTo(`${at}/became/movedTo`, became.movedTo, ['plan']);
|
|
126
|
+
if (plans.get(String(w.plan))?.status === 'Replaced' && !became) add(at, `its plan ${String(w.plan)} is Replaced, so became must say what became of it`);
|
|
127
|
+
});
|
|
128
|
+
((table.code as Json[]) ?? []).forEach((t, i) => {
|
|
129
|
+
refTo(`/code/${i}/area`, t.area, ['area']);
|
|
130
|
+
((t.alsoAreas as unknown[]) ?? []).forEach((id, j) => refTo(`/code/${i}/alsoAreas/${j}`, id, ['area']));
|
|
131
|
+
});
|
|
132
|
+
((table.notes as Json[]) ?? []).forEach((n, i) => {
|
|
133
|
+
((n.on as unknown[]) ?? []).forEach((id, j) => refTo(`/notes/${i}/on/${j}`, id, [...ANY_PROJECT, 'owner want']));
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// ── sources, against the project at its asOf commits ──
|
|
137
|
+
const repos = resolveRepos(table, projectDir, add);
|
|
138
|
+
const sessionIndex = sessions ? new Map(sessions.map((m) => [`${m.session}#${m.message}`, m.text])) : null;
|
|
139
|
+
const fileCache = new Map<string, string | null>();
|
|
140
|
+
const checkSource = (at: string, s: TableSource) => {
|
|
141
|
+
if (s.file !== undefined) {
|
|
142
|
+
const text = fileAt(s.file, repos, projectDir, fileCache);
|
|
143
|
+
if (text === null) { add(at, `the file ${s.file} is not in the project at its asOf commit`); return; }
|
|
144
|
+
let cited = text;
|
|
145
|
+
if (s.lines !== undefined) {
|
|
146
|
+
const [from, to = from] = s.lines.split('-').map(Number) as [number, number?];
|
|
147
|
+
const all = text.split(/\r?\n/);
|
|
148
|
+
if (from < 1 || to < from || to > all.length) { add(at, `lines ${s.lines} are outside ${s.file}, which has ${all.length} lines`); return; }
|
|
149
|
+
cited = all.slice(from - 1, to).join('\n');
|
|
150
|
+
}
|
|
151
|
+
if (s.quote !== undefined && !squash(cited).includes(squash(s.quote))) add(`${at}/quote`, `the quote does not stand word for word in ${s.file}${s.lines ? ` lines ${s.lines}` : ''}`);
|
|
152
|
+
} else if (s.commit !== undefined) {
|
|
153
|
+
const repo = repos.find((r) => normal(r.path) === normal(s.repo ?? '.'));
|
|
154
|
+
const dir = repo?.dir ?? join(projectDir, s.repo ?? '.');
|
|
155
|
+
if (!git(dir, ['cat-file', '-e', `${s.commit}^{commit}`]).ok) add(at, `the commit ${s.commit} is not in ${s.repo ?? 'the project repository'}`);
|
|
156
|
+
} else if (s.session !== undefined) {
|
|
157
|
+
if (!sessionIndex) { unchecked++; return; }
|
|
158
|
+
const text = sessionIndex.get(`${s.session}#${s.message}`);
|
|
159
|
+
if (text === undefined) { add(at, `message ${s.message} of ${s.session} is not in the session material`); return; }
|
|
160
|
+
if (s.quote !== undefined && !squash(text).includes(squash(s.quote))) add(`${at}/quote`, `the quote does not stand word for word in message ${s.message} of ${s.session}`);
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
let unchecked = 0;
|
|
164
|
+
walkSources(table, '', checkSource);
|
|
165
|
+
if (unchecked) add('/', `the table cites sessions ${unchecked} time${unchecked === 1 ? '' : 's'}; check it with the session material (--sessions <file>)`);
|
|
166
|
+
|
|
167
|
+
// ── code paths ──
|
|
168
|
+
((table.code as Json[]) ?? []).forEach((t, i) => {
|
|
169
|
+
((t.paths as string[]) ?? []).forEach((p, j) => {
|
|
170
|
+
if (!pathAt(p, repos, projectDir)) add(`/code/${i}/paths/${j}`, `${p} is not in the project at its asOf commit`);
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
return problems;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Check a table file: the shape first, and the facts only when the shape holds. */
|
|
177
|
+
export function checkTableFile(tableFile: string, options: { projectDir?: string; sessions?: readonly SessionMessage[] | null } = {}): TableProblem[] {
|
|
178
|
+
let table: unknown;
|
|
179
|
+
try { table = JSON.parse(readFileSync(tableFile, 'utf8')); } catch (e) { return [{ at: '/', problem: `not readable as JSON: ${(e as Error).message}` }]; }
|
|
180
|
+
const shape = checkShape(table);
|
|
181
|
+
if (shape.length) return shape;
|
|
182
|
+
const projectDir = options.projectDir ?? resolve(dirname(tableFile), '..');
|
|
183
|
+
return checkFacts(table as Json, projectDir, options.sessions ?? null);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* What `pk check` says about a table file, line by line: that it has no problems, or how many and each one with where
|
|
188
|
+
* it is. The command line prints these lines and the MCP server's table check returns them (D108), so both say the same.
|
|
189
|
+
*/
|
|
190
|
+
export function tableCheckReport(tableFile: string, problems: readonly TableProblem[]): string[] {
|
|
191
|
+
if (!problems.length) return [`${tableFile}: no problems.`];
|
|
192
|
+
return [`${tableFile}: ${problems.length} problem${problems.length === 1 ? '' : 's'}; the table is not taken in until there are none.`, ...problems.map((p) => ` ${p.at} ${p.problem}`)];
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** The session material as `pk` writes it: one JSON object per line with session, message and text. */
|
|
196
|
+
export function readSessionMaterial(file: string): SessionMessage[] {
|
|
197
|
+
return readFileSync(file, 'utf8').split(/\r?\n/).filter((l) => l.trim()).map((l) => JSON.parse(l) as SessionMessage);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// ───────────────────────── the owner part ─────────────────────────
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* The owner part: what the first step of a takeover writes (template/FILLING-IN.md, "Who fills what") — the reader of
|
|
204
|
+
* the owner's words fills `ownerWants` and its own notes from the session material alone, as
|
|
205
|
+
* `{ "ownerWants": [...], "notes": [...] }`, and the main agent merges it into the table later. `pk check --owner-part`
|
|
206
|
+
* holds it to what the whole table will be held to, as far as that step can answer for it: the two parts' shape
|
|
207
|
+
* against the schema's OwnerWant and Note, and the facts — ids, `replacedBy` naming an owner want, a `Replaced` want
|
|
208
|
+
* naming one, notes on owner wants only and by "Owner's words", `reflectedIn` left to the main agent, and every source
|
|
209
|
+
* a message of the session material with its quote word for word (D107).
|
|
210
|
+
*
|
|
211
|
+
* A record of the material that says who spoke (`pk sessions` writes `role`) also holds `said` to the owner's messages
|
|
212
|
+
* and an approved proposal to the agent's: an agent's message is never the owner's words.
|
|
213
|
+
*/
|
|
214
|
+
export function checkOwnerPart(part: unknown, sessions: readonly (SessionMessage & { readonly role?: string })[]): TableProblem[] {
|
|
215
|
+
const table = JSON.parse(readFileSync(SCHEMA_FILE, 'utf8')) as Json & { properties: Json; $defs: Json };
|
|
216
|
+
// The table's own definitions under a root of the two parts; no $id, so it is not taken for the table's schema.
|
|
217
|
+
const schema = {
|
|
218
|
+
type: 'object', additionalProperties: false, required: ['ownerWants', 'notes'],
|
|
219
|
+
properties: { ownerWants: table.properties.ownerWants, notes: table.properties.notes }, $defs: table.$defs,
|
|
220
|
+
};
|
|
221
|
+
const [ok, errors] = Schema.Errors(schema, part);
|
|
222
|
+
if (!ok) return errors.map((e) => ({ at: e.instancePath || '/', problem: e.message ?? 'does not match the schema' }));
|
|
223
|
+
|
|
224
|
+
const problems: TableProblem[] = [];
|
|
225
|
+
const add = (at: string, problem: string) => problems.push({ at, problem });
|
|
226
|
+
const { ownerWants, notes } = part as { ownerWants: Json[]; notes: Json[] };
|
|
227
|
+
const seen = new Map<string, string>();
|
|
228
|
+
const claim = (at: string, id: string, kind: string) => {
|
|
229
|
+
if (seen.has(id)) add(at, `the id "${id}" is already used by ${an(seen.get(id)!)}`);
|
|
230
|
+
else seen.set(id, kind);
|
|
231
|
+
};
|
|
232
|
+
ownerWants.forEach((w, i) => claim(`/ownerWants/${i}/id`, String(w.id), 'owner want'));
|
|
233
|
+
notes.forEach((n, i) => claim(`/notes/${i}/id`, String(n.id), 'note'));
|
|
234
|
+
const isWant = (id: unknown) => seen.get(String(id)) === 'owner want';
|
|
235
|
+
|
|
236
|
+
ownerWants.forEach((w, i) => {
|
|
237
|
+
const at = `/ownerWants/${i}`;
|
|
238
|
+
if (w.replacedBy !== undefined && !isWant(w.replacedBy)) add(`${at}/replacedBy`, `"${String(w.replacedBy)}" is not an owner want of this part`);
|
|
239
|
+
if (w.replacedBy !== undefined && w.replacedBy === w.id) add(`${at}/replacedBy`, 'a want cannot replace itself');
|
|
240
|
+
if (w.status === 'Replaced' && w.replacedBy === undefined) add(at, 'it is Replaced, so replacedBy must name the want that holds now');
|
|
241
|
+
if (((w.reflectedIn as unknown[]) ?? []).length) add(`${at}/reflectedIn`, 'leave it empty: the main agent fills reflectedIn once it has read both parts');
|
|
242
|
+
((w.said as TableSource[]) ?? []).forEach((s, j) => { if (s.quote === undefined) add(`${at}/said/${j}`, 'each saying carries its quote, copied word for word'); });
|
|
243
|
+
});
|
|
244
|
+
notes.forEach((n, i) => {
|
|
245
|
+
if (n.by !== "Owner's words") add(`/notes/${i}/by`, `this part's notes are by "Owner's words", not "${String(n.by)}"`);
|
|
246
|
+
((n.on as unknown[]) ?? []).forEach((id, j) => { if (!isWant(id)) add(`/notes/${i}/on/${j}`, `"${String(id)}" is not an owner want of this part; its notes are on its own wants`); });
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
// Every source is a message of the material: the owner part is read from nothing else.
|
|
250
|
+
const material = new Map(sessions.map((m) => [`${m.session}#${m.message}`, m]));
|
|
251
|
+
walkSources(part, '', (at, s) => {
|
|
252
|
+
if (s.session === undefined) { add(at, 'the owner part cites only the session material: a file or a commit is the project\'s part'); return; }
|
|
253
|
+
const m = material.get(`${s.session}#${s.message}`);
|
|
254
|
+
if (!m) { add(at, `message ${s.message} of ${s.session} is not in the session material`); return; }
|
|
255
|
+
if (s.quote !== undefined && !squash(m.text).includes(squash(s.quote))) add(`${at}/quote`, `the quote does not stand word for word in message ${s.message} of ${s.session}`);
|
|
256
|
+
if (/\/said\/\d+$/.test(at) && m.role === 'agent') add(at, `message ${s.message} of ${s.session} is the agent's; said quotes the owner (a short yes approves the agent's proposal: approvedProposal)`);
|
|
257
|
+
if (at.endsWith('/approvedProposal/source') && m.role === 'owner') add(at, `message ${s.message} of ${s.session} is the owner's; approvedProposal points at the agent message that proposed it`);
|
|
258
|
+
});
|
|
259
|
+
return problems;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** Check an owner-part file against the session material. */
|
|
263
|
+
export function checkOwnerPartFile(file: string, sessions: readonly SessionMessage[]): TableProblem[] {
|
|
264
|
+
let part: unknown;
|
|
265
|
+
try { part = JSON.parse(readFileSync(file, 'utf8')); } catch (e) { return [{ at: '/', problem: `not readable as JSON: ${(e as Error).message}` }]; }
|
|
266
|
+
return checkOwnerPart(part, sessions);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// ───────────────────────── helpers ─────────────────────────
|
|
270
|
+
|
|
271
|
+
const normal = (p: string): string => p.split('\\').join('/').replace(/^\.\/?/, '').replace(/\/$/, '');
|
|
272
|
+
|
|
273
|
+
export function resolveRepos(table: Json, projectDir: string, add: (at: string, problem: string) => void): Repo[] {
|
|
274
|
+
const header = table.project as Json;
|
|
275
|
+
const listed = ((header?.asOf as Json)?.repos as { path: string; commit: string }[]) ?? [];
|
|
276
|
+
const repos: Repo[] = [];
|
|
277
|
+
listed.forEach((r, i) => {
|
|
278
|
+
const dir = resolve(projectDir, r.path);
|
|
279
|
+
if (!existsSync(dir)) { add(`/project/asOf/repos/${i}/path`, `${r.path} is not a folder of the project`); return; }
|
|
280
|
+
if (!git(dir, ['cat-file', '-e', `${r.commit}^{commit}`]).ok) { add(`/project/asOf/repos/${i}/commit`, `the commit ${r.commit} is not in ${r.path}`); return; }
|
|
281
|
+
repos.push({ path: normal(r.path), commit: r.commit, dir });
|
|
282
|
+
});
|
|
283
|
+
// The deepest repository holding a path is the one it belongs to.
|
|
284
|
+
return repos.sort((a, b) => b.path.length - a.path.length);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export function owningRepo(path: string, repos: readonly Repo[]): { repo: Repo; inner: string } | null {
|
|
288
|
+
const p = normal(path);
|
|
289
|
+
for (const repo of repos) {
|
|
290
|
+
if (repo.path === '') return { repo, inner: p };
|
|
291
|
+
if (p === repo.path || p.startsWith(`${repo.path}/`)) return { repo, inner: p.slice(repo.path.length + 1) };
|
|
292
|
+
}
|
|
293
|
+
return null;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** A file's text at its repository's asOf commit; a file no repository tracks is read from the folder as it is. */
|
|
297
|
+
export function fileAt(path: string, repos: readonly Repo[], projectDir: string, cache: Map<string, string | null>): string | null {
|
|
298
|
+
const key = normal(path);
|
|
299
|
+
if (cache.has(key)) return cache.get(key)!;
|
|
300
|
+
let text: string | null = null;
|
|
301
|
+
const owner = owningRepo(path, repos);
|
|
302
|
+
if (owner) text = gitShow(owner.repo.dir, owner.repo.commit, owner.inner);
|
|
303
|
+
if (text === null) {
|
|
304
|
+
const full = resolve(projectDir, key);
|
|
305
|
+
if (inside(projectDir, full) && existsSync(full) && statSync(full).isFile()) text = readFileSync(full, 'utf8');
|
|
306
|
+
}
|
|
307
|
+
cache.set(key, text);
|
|
308
|
+
return text;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** Whether a file or a folder is there at its repository's asOf commit (or, untracked, in the folder). */
|
|
312
|
+
function pathAt(path: string, repos: readonly Repo[], projectDir: string): boolean {
|
|
313
|
+
const owner = owningRepo(path, repos);
|
|
314
|
+
if (owner && git(owner.repo.dir, ['cat-file', '-e', `${owner.repo.commit}:${owner.inner || '.'}`]).ok) return true;
|
|
315
|
+
const full = resolve(projectDir, normal(path));
|
|
316
|
+
return inside(projectDir, full) && existsSync(full);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function inside(root: string, full: string): boolean {
|
|
320
|
+
const rel = relative(resolve(root), full);
|
|
321
|
+
return rel === '' || (!rel.startsWith('..') && !rel.includes(`..${sep}`) && !/^[A-Za-z]:/.test(rel));
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** Every source anywhere in the table, with its pointer: `sources`, `said`, `source`, `accepted`. */
|
|
325
|
+
export function walkSources(node: unknown, at: string, visit: (at: string, s: TableSource) => void): void {
|
|
326
|
+
if (Array.isArray(node)) { node.forEach((v, i) => walkSources(v, `${at}/${i}`, visit)); return; }
|
|
327
|
+
if (!node || typeof node !== 'object') return;
|
|
328
|
+
for (const [key, value] of Object.entries(node as Json)) {
|
|
329
|
+
if (key === 'sources' || key === 'said') (value as TableSource[]).forEach((s, i) => visit(`${at}/${key}/${i}`, s));
|
|
330
|
+
else if (key === 'source' || key === 'accepted') visit(`${at}/${key}`, value as TableSource);
|
|
331
|
+
else walkSources(value, `${at}/${key}`, visit);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The table follow-up's one writer (D107): `pk_write_table` hands the whole updated table in. The program checks it as
|
|
3
|
+
* `pk check` does, and either takes it in or refuses it with the list of problems. The work is done by the writer the
|
|
4
|
+
* organizing service gives the runtime (keeper/organize/table-follow-up.ts `TableFollowUp.write`). This file only shapes
|
|
5
|
+
* the call, so the runtime does not load the planner.
|
|
6
|
+
*/
|
|
7
|
+
import { Type } from 'typebox';
|
|
8
|
+
import { defineTool, type ToolDefinition } from '@earendil-works/pi-coding-agent';
|
|
9
|
+
|
|
10
|
+
/** What the write answers: taken in, or refused with why. */
|
|
11
|
+
export interface TableWriteResult { readonly ok: boolean; readonly text: string }
|
|
12
|
+
/** Takes one version of the table in for one job: the table given, or else the job's working copy. */
|
|
13
|
+
export type TableWriter = (input: unknown) => Promise<TableWriteResult>;
|
|
14
|
+
|
|
15
|
+
export function tableFollowUpTools(writer: TableWriter, onTaken?: (text: string) => void): ToolDefinition[] {
|
|
16
|
+
return [
|
|
17
|
+
defineTool({
|
|
18
|
+
name: 'pk_write_table', label: 'Hand the table in',
|
|
19
|
+
description: 'Hand in the updated ProjectKeeper table. Leave `table` out to hand in your working copy (the file the brief names), which is the usual way: edit the copy, then call this. The program sets project.asOf and filledBy.agent/at, then checks the whole table: its shape, every id and reference, and every source against the project at that asOf. If anything fails, nothing is taken in and the answer lists each problem as `<where in the table> <what is wrong>`: fix them all and call again. If it holds, the table is saved and taken in as the project\'s picture, and the answer says what changed.',
|
|
20
|
+
parameters: Type.Object({
|
|
21
|
+
table: Type.Optional(Type.Unknown({ description: 'the whole table as one JSON object (or its JSON text); leave out to hand in your working copy' })),
|
|
22
|
+
}),
|
|
23
|
+
execute: async (_id, p) => {
|
|
24
|
+
const r = await writer((p as { table?: unknown }).table);
|
|
25
|
+
if (r.ok) onTaken?.(r.text);
|
|
26
|
+
return { content: [{ type: 'text' as const, text: r.ok ? r.text : `ERROR: ${r.text}` }], details: {}, ...(r.ok ? {} : { isError: true }) };
|
|
27
|
+
},
|
|
28
|
+
}) as unknown as ToolDefinition,
|
|
29
|
+
];
|
|
30
|
+
}
|