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,260 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Merging duplicate work items (Spec §1.4; CKC-06 AC-28; subagent/DECISIONS.md E64, E65). Deterministic; no model.
|
|
3
|
+
*
|
|
4
|
+
* The main job merges, at the end of its round, work items that turned out to be one unit of work. The merged one
|
|
5
|
+
* leaves the work items: its sources, facts and relations move to the one kept, everything that named it names the
|
|
6
|
+
* kept one, and a merge record keeps what it was and points to the one kept. So the list, the graph and every pack —
|
|
7
|
+
* all read from the work items — show it once, and its id still resolves to the one kept.
|
|
8
|
+
*
|
|
9
|
+
* It is not written as `Replaced`. `Replaced` is the history of something superseded, struck through and pointing to
|
|
10
|
+
* its successor; a duplicate is the same work written twice, and marking it `Replaced` would blur the one signal the
|
|
11
|
+
* owner reads for what was given up.
|
|
12
|
+
*/
|
|
13
|
+
import type { ItemRef, MergeRecord, ObjectJudgement, PropagationEntry, WorkThread } from '../model/types.ts';
|
|
14
|
+
import { stableId } from '../model/ids.ts';
|
|
15
|
+
import type { ProjectStore, TraceInfo } from '../store/project-store.ts';
|
|
16
|
+
import { sameItem } from './adjustment.ts';
|
|
17
|
+
import { isNumberShaped } from './numbers-check.ts';
|
|
18
|
+
|
|
19
|
+
const now = () => new Date().toISOString();
|
|
20
|
+
const unique = <T>(list: readonly T[]): T[] => [...new Set(list)];
|
|
21
|
+
/** `first` wins on a shared key; what only `more` has is added after it. */
|
|
22
|
+
const byKey = <T>(first: readonly T[], more: readonly T[], key: (x: T) => string): T[] => {
|
|
23
|
+
const out = new Map(first.map((x) => [key(x), x]));
|
|
24
|
+
for (const x of more) if (!out.has(key(x))) out.set(key(x), x);
|
|
25
|
+
return [...out.values()];
|
|
26
|
+
};
|
|
27
|
+
const unionRefs = <T extends ItemRef>(a: readonly T[], b: readonly T[]): T[] => [...a, ...b.filter((x) => !a.some((y) => sameItem(x, y)))];
|
|
28
|
+
|
|
29
|
+
/** The merge record that took this id out of the work items, or null. */
|
|
30
|
+
export function mergeOf(store: ProjectStore, id: string): MergeRecord | null {
|
|
31
|
+
return store.merges.find((m) => m.mergedId === id) ?? null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The id that stands for this one now: itself, or the work item it was merged into — following a later merge of that
|
|
36
|
+
* one too. Every tool that takes a work item id reads it through this, so an id a job still remembers reaches the
|
|
37
|
+
* kept work item instead of bringing the duplicate back.
|
|
38
|
+
*/
|
|
39
|
+
export function resolveMergedId(store: ProjectStore, id: string): string {
|
|
40
|
+
let current = id;
|
|
41
|
+
const seen = new Set<string>();
|
|
42
|
+
for (;;) {
|
|
43
|
+
if (seen.has(current)) return current;
|
|
44
|
+
seen.add(current);
|
|
45
|
+
const m = mergeOf(store, current);
|
|
46
|
+
if (!m) {
|
|
47
|
+
// CJ: a project number (`CKC-07`, `AP`) names the one work item that carries it, where no asset has it as its id —
|
|
48
|
+
// a work item's number lives in its ids, and its id is the program's.
|
|
49
|
+
const up = current.trim().toUpperCase();
|
|
50
|
+
if (!store.threads.has(current) && !store.reference.has(current) && isNumberShaped(up)) {
|
|
51
|
+
const carriers = store.threads.filter((t) => t.ids.some((x) => x.trim().toUpperCase() === up));
|
|
52
|
+
if (carriers.length === 1 && !seen.has(carriers[0]!.id)) { current = carriers[0]!.id; continue; }
|
|
53
|
+
}
|
|
54
|
+
return current;
|
|
55
|
+
}
|
|
56
|
+
current = m.keptId;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Why this merge cannot be done, or null. Every id is checked before anything is written. */
|
|
61
|
+
export function mergeRefusal(store: ProjectStore, keepId: string, mergeIds: readonly string[], reason: string): string | null {
|
|
62
|
+
if (!reason.trim()) return 'Say why these are one unit of work (reason): the same number in the project’s index, the same task named in a receipt or a hand-over. The merge record keeps it, so the merge can be checked and undone by hand if it was wrong.';
|
|
63
|
+
const kept = resolveMergedId(store, keepId);
|
|
64
|
+
if (!store.threads.has(kept)) return `${keepId} is not a work item in the assets, so there is nothing to keep. Give the id of the work item that stays (pk_read_assets kind thread lists them).`;
|
|
65
|
+
if (mergeIds.length === 0) return 'Name the duplicate work item(s) to merge into the one kept (mergeIds).';
|
|
66
|
+
for (const id of mergeIds) {
|
|
67
|
+
// The kept id was itself merged into this one earlier: merging this one into it would go round in a circle.
|
|
68
|
+
if (kept !== keepId && id === kept) return `${keepId} was merged into ${id} earlier, so merging ${id} into ${keepId} would make a circle. ${id} is the work item that stays; merge into it instead.`;
|
|
69
|
+
if (id === keepId || id === kept) return `A work item is not merged into itself: ${id} is the one being kept. Name the duplicate(s) in mergeIds.`;
|
|
70
|
+
const target = resolveMergedId(store, id);
|
|
71
|
+
if (target !== id) {
|
|
72
|
+
return target === kept
|
|
73
|
+
? `${id} is already merged into ${kept}; its id already reaches ${kept}, and a second merge would add nothing.`
|
|
74
|
+
: `${id} was merged into ${target} earlier. If ${target} is the same unit of work as ${kept}, merge ${target} into ${kept}.`;
|
|
75
|
+
}
|
|
76
|
+
if (!store.threads.has(id)) return `${id} is not a work item in the assets. Merging is for work items that turned out to be one unit of work.`;
|
|
77
|
+
}
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface MergeInput {
|
|
82
|
+
readonly projectId: string;
|
|
83
|
+
readonly keepId: string;
|
|
84
|
+
readonly mergeIds: readonly string[];
|
|
85
|
+
readonly reason: string;
|
|
86
|
+
readonly sourceIds: readonly string[];
|
|
87
|
+
readonly jobId: string | null;
|
|
88
|
+
readonly roundId: string | null;
|
|
89
|
+
}
|
|
90
|
+
export interface MergeOutcome {
|
|
91
|
+
readonly keptId: string;
|
|
92
|
+
readonly merged: readonly string[];
|
|
93
|
+
/** How many records of each kind now name the kept work item instead of a merged one. */
|
|
94
|
+
readonly moved: Readonly<Record<string, number>>;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The kept work item with what the merged one brings: its numbers, facts, fact records, what it serves and needs. */
|
|
98
|
+
function combine(kept: WorkThread, merged: WorkThread, at: string): WorkThread {
|
|
99
|
+
return {
|
|
100
|
+
...kept,
|
|
101
|
+
ids: unique([...kept.ids, ...merged.ids]),
|
|
102
|
+
doing: kept.doing || merged.doing,
|
|
103
|
+
changed: kept.changed || merged.changed,
|
|
104
|
+
results: kept.results || merged.results,
|
|
105
|
+
unresolved: kept.unresolved || merged.unresolved,
|
|
106
|
+
doneMeans: kept.doneMeans || merged.doneMeans || '',
|
|
107
|
+
acceptanceMeans: kept.acceptanceMeans || merged.acceptanceMeans || '',
|
|
108
|
+
acceptance: kept.acceptance || merged.acceptance || '',
|
|
109
|
+
executionFacts: byKey(kept.executionFacts, merged.executionFacts, (s) => s.text),
|
|
110
|
+
qcFacts: byKey(kept.qcFacts, merged.qcFacts, (s) => s.text),
|
|
111
|
+
factRecordIds: unique([...kept.factRecordIds, ...merged.factRecordIds]),
|
|
112
|
+
serves: byKey(kept.serves, merged.serves, (s) => s.referenceId),
|
|
113
|
+
dependsOn: byKey(kept.dependsOn, merged.dependsOn, (d) => d.threadId).filter((d) => d.threadId !== kept.id && d.threadId !== merged.id),
|
|
114
|
+
pendingSourceIds: unique([...kept.pendingSourceIds, ...merged.pendingSourceIds]),
|
|
115
|
+
validityByRuleId: kept.validityByRuleId ?? merged.validityByRuleId ?? null,
|
|
116
|
+
progressByRuleId: kept.progressByRuleId ?? merged.progressByRuleId ?? null,
|
|
117
|
+
updatedAt: at,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** One object's entry in a change record, with the merged one's folded in: behind if either was behind. */
|
|
122
|
+
function foldEntry(mine: PropagationEntry, theirs: PropagationEntry): PropagationEntry {
|
|
123
|
+
const behind = [mine, theirs].find((p) => p.state === 'Still on old understanding');
|
|
124
|
+
return {
|
|
125
|
+
...mine,
|
|
126
|
+
state: behind ? behind.state : mine.state,
|
|
127
|
+
sourceOrReason: unique([mine.sourceOrReason, theirs.sourceOrReason].filter((x) => x.trim())).join(' · '),
|
|
128
|
+
// No `itemIds` means the whole record reached the object, so the fold is the whole record too.
|
|
129
|
+
...(mine.itemIds && theirs.itemIds ? { itemIds: unique([...mine.itemIds, ...theirs.itemIds]) } : { itemIds: undefined }),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Every record that names `from` names `to` instead. Records of what happened (jobs, contexts, round results) stay. */
|
|
134
|
+
function repoint(store: ProjectStore, from: string, to: string, moved: Record<string, number>, trace: TraceInfo): void {
|
|
135
|
+
const swap = (id: string) => (id === from ? to : id);
|
|
136
|
+
const count = (kind: string) => { moved[kind] = (moved[kind] ?? 0) + 1; };
|
|
137
|
+
|
|
138
|
+
for (const t of store.threads.all()) {
|
|
139
|
+
if (t.id === from || !t.dependsOn.some((d) => d.threadId === from)) continue;
|
|
140
|
+
const dependsOn = byKey(t.dependsOn.map((d) => ({ ...d, threadId: swap(d.threadId) })), [], (d) => d.threadId).filter((d) => d.threadId !== t.id);
|
|
141
|
+
store.threads.put({ ...t, dependsOn }, trace);
|
|
142
|
+
count('workItems');
|
|
143
|
+
}
|
|
144
|
+
for (const a of store.areas.all()) {
|
|
145
|
+
if (!a.contributions.some((c) => c.threadId === from)) continue;
|
|
146
|
+
store.areas.put({ ...a, contributions: byKey(a.contributions.map((c) => ({ ...c, threadId: swap(c.threadId) })), [], (c) => c.threadId) }, trace);
|
|
147
|
+
count('areas');
|
|
148
|
+
}
|
|
149
|
+
for (const r of store.relations.all()) {
|
|
150
|
+
if (r.from !== from && r.to !== from) continue;
|
|
151
|
+
const f = swap(r.from);
|
|
152
|
+
const t = swap(r.to);
|
|
153
|
+
store.relations.remove(r.id, trace);
|
|
154
|
+
count('relations');
|
|
155
|
+
// The two duplicates were related to each other; one work item is not related to itself.
|
|
156
|
+
if (f === t) continue;
|
|
157
|
+
const id = stableId('rel', r.type, f, t);
|
|
158
|
+
const existing = store.relations.get(id);
|
|
159
|
+
if (existing) {
|
|
160
|
+
store.relations.put({ ...existing, evidence: {
|
|
161
|
+
sourceIds: unique([...existing.evidence.sourceIds, ...r.evidence.sourceIds]),
|
|
162
|
+
factRecordIds: unique([...existing.evidence.factRecordIds, ...r.evidence.factRecordIds]),
|
|
163
|
+
factsSoFar: existing.evidence.factsSoFar || r.evidence.factsSoFar,
|
|
164
|
+
} }, trace);
|
|
165
|
+
} else {
|
|
166
|
+
store.relations.put({ ...r, id, from: f, to: t }, trace);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
store.nodes.remove(from, trace);
|
|
170
|
+
for (const n of store.nodes.all()) if (n.parentWorkId === from) store.nodes.put({ ...n, parentWorkId: to });
|
|
171
|
+
for (const m of store.marks.all()) {
|
|
172
|
+
if (m.targetId !== from) continue;
|
|
173
|
+
store.marks.remove(m.id, trace);
|
|
174
|
+
count('marks');
|
|
175
|
+
// One mark per kind and object (as pk_write_mark keeps them): the same doubt on both becomes one, with both clues.
|
|
176
|
+
const id = stableId('mark', m.kind, to);
|
|
177
|
+
const existing = store.marks.get(id);
|
|
178
|
+
if (!existing || (existing.closed && !m.closed)) store.marks.put({ ...m, id, targetId: to }, trace);
|
|
179
|
+
else if (!existing.closed && !m.closed) {
|
|
180
|
+
store.marks.put({ ...existing, clue: existing.clue === m.clue ? existing.clue : `${existing.clue} · ${m.clue}`, clueSourceIds: unique([...existing.clueSourceIds, ...m.clueSourceIds]) }, trace);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
for (const n of store.notes.all()) {
|
|
184
|
+
if (!n.mount.ids.includes(from)) continue;
|
|
185
|
+
store.notes.put({ ...n, mount: { ...n.mount, ids: unique(n.mount.ids.map(swap)) } }, trace);
|
|
186
|
+
count('notes');
|
|
187
|
+
}
|
|
188
|
+
for (const c of store.changes.all()) {
|
|
189
|
+
const touches = c.affects.includes(from) || (c.items ?? []).some((i) => i.affects.includes(from))
|
|
190
|
+
|| c.propagation.some((p) => p.nodeId === from) || (c.notJudged ?? []).some((e) => e.nodeId === from);
|
|
191
|
+
if (!touches) continue;
|
|
192
|
+
const items = c.items?.map((i) => ({ ...i, affects: unique(i.affects.map(swap)) }));
|
|
193
|
+
const affects = unique(c.affects.map(swap));
|
|
194
|
+
const mine = c.propagation.find((p) => p.nodeId === to);
|
|
195
|
+
const theirs = c.propagation.find((p) => p.nodeId === from);
|
|
196
|
+
let propagation = c.propagation.filter((p) => p.nodeId !== from && p.nodeId !== to);
|
|
197
|
+
const folded = theirs ? (mine ? foldEntry(mine, theirs) : { ...theirs, nodeId: to }) : mine;
|
|
198
|
+
// A subject is never downstream of its own item (§1.8): what reached the kept one through an item that now changes
|
|
199
|
+
// it directly is dropped.
|
|
200
|
+
if (folded) {
|
|
201
|
+
const subjectOf = new Set((items ?? []).filter((i) => i.affects.includes(to)).map((i) => i.id));
|
|
202
|
+
const still = folded.itemIds ? folded.itemIds.filter((id) => !subjectOf.has(id)) : folded.itemIds;
|
|
203
|
+
const whole = !folded.itemIds && affects.includes(to);
|
|
204
|
+
if (!whole && (!still || still.length > 0)) propagation = [...propagation, still ? { ...folded, itemIds: still } : folded];
|
|
205
|
+
}
|
|
206
|
+
const notJudged = c.notJudged ? byKey(c.notJudged.map((e) => ({ ...e, nodeId: swap(e.nodeId) })), [], (e) => e.nodeId) : undefined;
|
|
207
|
+
store.changes.put({ ...c, affects, propagation, ...(items ? { items } : {}), ...(notJudged ? { notJudged } : {}) }, trace);
|
|
208
|
+
count('changes');
|
|
209
|
+
}
|
|
210
|
+
for (const j of store.propagation.all()) {
|
|
211
|
+
if (j.nodeId !== from) continue;
|
|
212
|
+
store.propagation.remove(j.id, trace);
|
|
213
|
+
count('judgements');
|
|
214
|
+
const id = `${j.roundId}:${to}`;
|
|
215
|
+
const k = store.propagation.get(id);
|
|
216
|
+
if (!k) { store.propagation.put({ ...j, id, nodeId: to }, trace); continue; }
|
|
217
|
+
// The kept one's judgement of that round takes in what the merged one still lacked, and lacking wins.
|
|
218
|
+
const lacks = unionRefs(k.lacks, j.lacks);
|
|
219
|
+
const closed = unionRefs(k.closed, j.closed).filter((c) => !lacks.some((l) => sameItem(l, c)));
|
|
220
|
+
const followed = unionRefs(k.followed, j.followed).filter((f) => ![...lacks, ...closed].some((x) => sameItem(x, f)));
|
|
221
|
+
const judgement: ObjectJudgement = {
|
|
222
|
+
...k, covers: unionRefs(k.covers, j.covers), lacks, closed, followed,
|
|
223
|
+
state: lacks.length ? 'Still on old understanding' : k.state,
|
|
224
|
+
sourceOrReason: unique([k.sourceOrReason, j.sourceOrReason].filter((x) => x.trim())).join(' · '),
|
|
225
|
+
};
|
|
226
|
+
store.propagation.put(judgement, trace);
|
|
227
|
+
}
|
|
228
|
+
for (const r of store.requests.all()) {
|
|
229
|
+
if (!r.impact.includes(from) && !(r.lines ?? []).some((l) => l.nodeId === from)) continue;
|
|
230
|
+
store.requests.put({ ...r, impact: unique(r.impact.map(swap)), ...(r.lines ? { lines: r.lines.map((l) => ({ ...l, nodeId: swap(l.nodeId) })) } : {}) }, trace);
|
|
231
|
+
count('requests');
|
|
232
|
+
}
|
|
233
|
+
for (const ref of store.reference.all()) {
|
|
234
|
+
if (!ref.carryOut || !ref.carryOut.workIds.includes(from)) continue;
|
|
235
|
+
store.reference.put({ ...ref, carryOut: { ...ref.carryOut, workIds: unique(ref.carryOut.workIds.map(swap)) } }, trace);
|
|
236
|
+
count('decisions');
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Merge the named duplicates into the work item kept. Call `mergeRefusal` first; this assumes the ids are valid. */
|
|
241
|
+
export function mergeWorkItems(store: ProjectStore, input: MergeInput): MergeOutcome {
|
|
242
|
+
const keptId = resolveMergedId(store, input.keepId);
|
|
243
|
+
const moved: Record<string, number> = {};
|
|
244
|
+
const merged: string[] = [];
|
|
245
|
+
for (const mergedId of unique(input.mergeIds)) {
|
|
246
|
+
const at = now();
|
|
247
|
+
const kept = store.threads.get(keptId)!;
|
|
248
|
+
const duplicate = store.threads.get(mergedId)!;
|
|
249
|
+
const trace: TraceInfo = { jobId: input.jobId, basisSourceIds: input.sourceIds, summary: `Merged ${mergedId} (${duplicate.title}) into ${keptId} (${kept.title}): ${input.reason}` };
|
|
250
|
+
store.threads.put(combine(kept, duplicate, at), trace);
|
|
251
|
+
repoint(store, mergedId, keptId, moved, trace);
|
|
252
|
+
store.threads.remove(mergedId, trace);
|
|
253
|
+
store.merges.put({
|
|
254
|
+
id: stableId('merge', input.projectId, mergedId), projectId: input.projectId, kind: 'thread', mergedId, keptId,
|
|
255
|
+
reason: input.reason, sourceIds: [...input.sourceIds], merged: duplicate, at, jobId: input.jobId, roundId: input.roundId,
|
|
256
|
+
}, trace);
|
|
257
|
+
merged.push(mergedId);
|
|
258
|
+
}
|
|
259
|
+
return { keptId, merged, moved };
|
|
260
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How a note came about (Spec §4.1, §4.2; D47 supplement, D56 item 5): `Product re-look`, `Change follow-up`,
|
|
3
|
+
* `Investigation`, `Owner question`, `Organizing` (written while material was being organized) or `Takeover` (written
|
|
4
|
+
* while the project was being taken over), named from the work that wrote it. A note from a change follow-up also names
|
|
5
|
+
* the changes it is about, so the owner can tell at a glance which reminders it brought (§6.2).
|
|
6
|
+
*
|
|
7
|
+
* By the clerk method (Spec v3.0 §3.3) a note is written by a step of a round — the synthesis, which is the round's product
|
|
8
|
+
* re-look, or the spot-check — and the round says how it came about: a note of a Follow up round came from following the
|
|
9
|
+
* changes up; one of the first usable round or a deepening, from taking the project over. The D59 scopes this used to
|
|
10
|
+
* read (`follow-up`, `change-follow-up`, `round`, `frame`, `takeover`) belong to jobs nothing creates any more.
|
|
11
|
+
*/
|
|
12
|
+
import type { KeeperJob, NoteCameFrom } from '../model/types.ts';
|
|
13
|
+
import type { NoteOrigin } from '../model/vocab.ts';
|
|
14
|
+
import type { ProjectStore } from '../store/project-store.ts';
|
|
15
|
+
import { clerkRoundOfJob } from './roles.ts';
|
|
16
|
+
|
|
17
|
+
/** How a note came about from the kind of work outside a round: a re-look, an investigation, the owner's conversation. */
|
|
18
|
+
export function originOfJob(job: Pick<KeeperJob, 'kind' | 'scope'> | undefined | null): NoteOrigin | null {
|
|
19
|
+
if (!job) return null;
|
|
20
|
+
if (job.kind === 'Product re-look') return 'Product re-look';
|
|
21
|
+
if (job.kind === 'Investigation') return 'Investigation';
|
|
22
|
+
if (job.kind === 'Answering' || job.kind === 'Your request') return 'Owner question';
|
|
23
|
+
if (job.kind === 'Organizing') return 'Organizing';
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The changes a follow-up note is about: of the changes the round took up (every change when that is not known), those
|
|
29
|
+
* whose downstream includes the object the note is mounted on.
|
|
30
|
+
*/
|
|
31
|
+
export function followUpChangesFor(store: ProjectStore, mountIds: readonly string[], candidates: readonly string[] | null): string[] {
|
|
32
|
+
const pool = candidates ? candidates.flatMap((id) => store.changes.get(id) ?? []) : store.changes.all();
|
|
33
|
+
return pool.filter((c) => c.propagation.some((p) => mountIds.includes(p.nodeId)) || c.affects.some((id) => mountIds.includes(id))).map((c) => c.id);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The changes a Follow up round took up: those its cross-check judged objects against, in the round's record (§5.5). */
|
|
37
|
+
function changesOfFollowUp(store: ProjectStore, recordId: string | null): string[] | null {
|
|
38
|
+
if (!recordId) return null;
|
|
39
|
+
const ids = store.propagation.filter((j) => j.roundId === recordId).flatMap((j) => [...j.covers, ...j.followed, ...j.lacks].map((i) => i.changeId));
|
|
40
|
+
return ids.length ? [...new Set(ids)] : null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function cameFromFor(
|
|
44
|
+
store: ProjectStore, job: KeeperJob | undefined | null, mountIds: readonly string[],
|
|
45
|
+
opts: { readonly given?: readonly string[]; readonly candidates?: readonly string[] | null } = {},
|
|
46
|
+
): NoteCameFrom {
|
|
47
|
+
const round = job ? clerkRoundOfJob(store, job) : null;
|
|
48
|
+
const kind: NoteOrigin | null = round ? (round.kind === 'Follow up' ? 'Change follow-up' : 'Takeover') : originOfJob(job);
|
|
49
|
+
let changeIds: string[] = [];
|
|
50
|
+
if (kind === 'Change follow-up') {
|
|
51
|
+
const given = (opts.given ?? []).filter((id) => store.changes.has(id));
|
|
52
|
+
const known = opts.candidates ?? changesOfFollowUp(store, round?.followUpRoundId ?? null);
|
|
53
|
+
changeIds = given.length ? given : followUpChangesFor(store, mountIds, known);
|
|
54
|
+
}
|
|
55
|
+
return { kind, jobKind: job?.kind ?? null, jobId: job?.id ?? null, changeIds };
|
|
56
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the writers check against the ledger's numbering (CJ; Spec §1.16 row 4; CKC-23 AC-21, CKC-24): a work item's ids
|
|
3
|
+
* are numbers the project defines, and not another object's number; an item named by its number alone is named as its
|
|
4
|
+
* document names it. Without a ledger (it is built by the round's first step), or a ledger that recognised no numbering,
|
|
5
|
+
* nothing is checked.
|
|
6
|
+
*/
|
|
7
|
+
import { Ledger } from '../ledger/index.ts';
|
|
8
|
+
import { familyOf } from '../ledger/numbering.ts';
|
|
9
|
+
import type { ProjectStore } from '../store/project-store.ts';
|
|
10
|
+
import { pathKey } from '../util/paths.ts';
|
|
11
|
+
import { entryTitle, isNumberOnly } from './organize/entries.ts';
|
|
12
|
+
|
|
13
|
+
/** A project number as written (`CKC-22`, `D99`, `AP`, `CK-M2`, `SOL-PI-0871`): the shapes the ledger and the writers read. */
|
|
14
|
+
const NUMBER_SHAPE = /^(?:[A-Z]{1,6}(?:-[A-Z]{1,4})?-?\d{1,4}(?:\.\d{1,3})*|[A-Z]{2})$/;
|
|
15
|
+
export const isNumberShaped = (s: string): boolean => NUMBER_SHAPE.test(s.trim()) && !/^V\d/.test(s.trim());
|
|
16
|
+
|
|
17
|
+
/** Reference categories whose numbers are never a work item's own: a decision's, a boundary's, a goal's, a module's. */
|
|
18
|
+
const NOT_A_TICKET: ReadonlySet<string> = new Set(['Decision', 'Boundary', "Owner's words", 'Product', 'Goal', 'Area', 'Design']);
|
|
19
|
+
|
|
20
|
+
/** Run with the project's ledger when it has one that recognised a numbering; the ledger is closed after. */
|
|
21
|
+
export function withNumbering<T>(store: ProjectStore, fn: (ledger: Ledger) => T, otherwise: T): T {
|
|
22
|
+
let ledger: Ledger | null = null;
|
|
23
|
+
try { ledger = Ledger.openDir(store.dir); } catch { ledger = null; }
|
|
24
|
+
if (!ledger) return otherwise;
|
|
25
|
+
try {
|
|
26
|
+
const rules = ledger.db.prepare('SELECT count(*) c FROM num_rules').get() as { c: number } | undefined;
|
|
27
|
+
if (!rules || rules.c === 0) return otherwise;
|
|
28
|
+
return fn(ledger);
|
|
29
|
+
} catch {
|
|
30
|
+
return otherwise;
|
|
31
|
+
} finally {
|
|
32
|
+
ledger.close();
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The documents the layer map marks as current decision records, as `repo\0path` keys. */
|
|
37
|
+
function decisionRecordKeys(store: ProjectStore): Set<string> {
|
|
38
|
+
return new Set(store.layers.filter((l) => l.layer === 'Decision record').map((l) => `${pathKey(l.repo ?? '')}\u0000${l.path.replace(/\\/g, '/').replace(/\/+$/, '')}`));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Whose number this is when it is not a work item's: a decision, boundary, goal or module that carries it, or the decision
|
|
43
|
+
* record that alone defines it (`D99`, `E145`). Null when it may be a work item's own number.
|
|
44
|
+
*/
|
|
45
|
+
export function otherObjectOf(store: ProjectStore, ledger: Ledger | null, num: string): string | null {
|
|
46
|
+
const n = num.trim().toUpperCase();
|
|
47
|
+
const item = store.reference.find((r) => NOT_A_TICKET.has(r.category) && r.ids.some((x) => x.trim().toUpperCase() === n));
|
|
48
|
+
if (item) return `the ${item.category} ${item.ids[0] ?? ''} “${item.name.slice(0, 60)}”`;
|
|
49
|
+
if (!ledger) return null;
|
|
50
|
+
const records = decisionRecordKeys(store);
|
|
51
|
+
if (!records.size) return null;
|
|
52
|
+
const places = ledger.definitionPlaces(n);
|
|
53
|
+
if (!places.length) return null;
|
|
54
|
+
const repos = new Map(ledger.repos().map((r) => [r.id, r.path]));
|
|
55
|
+
const inRecord = (d: { repo: string | null; path: string }) => [...records].some((k) => {
|
|
56
|
+
const [repo, path] = k.split('\u0000') as [string, string];
|
|
57
|
+
return pathKey(repos.get(d.repo ?? '') ?? '') === repo && (d.path === path || d.path.startsWith(`${path}/`));
|
|
58
|
+
});
|
|
59
|
+
if (places.every(inRecord)) return `a decision the decision record ${places[0]!.path} defines`;
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The ids the project does not have as its numbers: of a numbering the ledger recognised (or two capitals), one it never
|
|
65
|
+
* defines; of any other shape, one no document, commit or session the ledger read writes at all (`KIMI-P1`,
|
|
66
|
+
* `SOL-PI-0871`: a label of the writer's own).
|
|
67
|
+
*/
|
|
68
|
+
export function undefinedNumbers(ledger: Ledger, ids: readonly string[]): string[] {
|
|
69
|
+
const shaped = ids.map((i) => i.trim()).filter((i) => isNumberShaped(i.toUpperCase()));
|
|
70
|
+
if (!shaped.length) return [];
|
|
71
|
+
const defined = ledger.definedNumbers(shaped.map((s) => s.toUpperCase()));
|
|
72
|
+
const rules = new Set((ledger.db.prepare('SELECT rule FROM num_rules').all() as { rule: string }[]).map((r) => r.rule));
|
|
73
|
+
return shaped.filter((s) => {
|
|
74
|
+
const up = s.toUpperCase();
|
|
75
|
+
if (defined.has(up)) return false;
|
|
76
|
+
const family = familyOf(up);
|
|
77
|
+
if (family && (family.shape === 'two-letters' || rules.has(family.family))) return true;
|
|
78
|
+
return !ledger.hasToken(up);
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Why a name that is the number alone is refused, or null (CKC-23 AC-21: named as the document names it): the line that
|
|
84
|
+
* defines the number — in a document the item cites, else anywhere current — gives a title besides the number.
|
|
85
|
+
*/
|
|
86
|
+
export function numberOnlyRefusal(store: ProjectStore, ledger: Ledger, name: string, sourceIds: readonly string[], what: string): string | null {
|
|
87
|
+
if (!isNumberOnly(name)) return null;
|
|
88
|
+
const num = name.replace(/[^A-Za-z0-9-]/g, '').toUpperCase();
|
|
89
|
+
if (!familyOf(num) && !isNumberShaped(num)) return null;
|
|
90
|
+
const defs = ledger.definitionsOf(num).filter((d) => d.position === 'heading' || d.position === 'bold entry' || d.position === 'table first column');
|
|
91
|
+
if (!defs.length) return null;
|
|
92
|
+
const cited = new Set(sourceIds.map((id) => store.sources.get(id)).flatMap((s) => (s && s.anchor.kind === 'file' ? [pathKey(s.anchor.path)] : [])));
|
|
93
|
+
const repos = new Map(ledger.repos().map((r) => [r.id, r.path]));
|
|
94
|
+
const absKey = (d: { repo: string | null; path: string }) => pathKey(`${repos.get(d.repo ?? '') ?? ''}/${d.path}`);
|
|
95
|
+
const ordered = [...defs.filter((d) => cited.has(absKey(d))), ...defs.filter((d) => !cited.has(absKey(d)))];
|
|
96
|
+
for (const d of ordered) {
|
|
97
|
+
const t = entryTitle(d.context, num, d.position as 'heading');
|
|
98
|
+
if (!t.title) continue;
|
|
99
|
+
return `${what} “${name}” is its number alone, and the line that defines ${num} (${d.path}:${d.line}) gives its title: “${d.context.slice(0, 160)}”. Name it by its number and its title as written, e.g. “${t.name.slice(0, 120)}” (named as the document names it). Nothing was written.`;
|
|
100
|
+
}
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Opening pi's own interface in a terminal, for `Open in pi` on a system that is not Windows (runtime.ts keeps the
|
|
3
|
+
* Windows way: a console window started with `start`).
|
|
4
|
+
*
|
|
5
|
+
* macOS: a small script is written to the temporary directory and handed to Terminal (`open -a Terminal <script>`),
|
|
6
|
+
* which runs a `.command` file in a new window. That needs no permission from the owner; telling Terminal what to do
|
|
7
|
+
* through AppleScript would need macOS's leave for one program to control another, which can be refused without a
|
|
8
|
+
* word. What is reported is what happened: Terminal was asked and `open` said yes, or it was not and why.
|
|
9
|
+
*
|
|
10
|
+
* Anywhere else there is no one terminal to open, so nothing is started and the answer is the command to run. (Started
|
|
11
|
+
* with no terminal at all, as it used to be, pi showed nothing and the workbench said it had been opened.)
|
|
12
|
+
*/
|
|
13
|
+
import { spawnSync } from 'node:child_process';
|
|
14
|
+
import { accessSync, constants, mkdtempSync, writeFileSync } from 'node:fs';
|
|
15
|
+
import { tmpdir } from 'node:os';
|
|
16
|
+
import { delimiter, join } from 'node:path';
|
|
17
|
+
import { fileURLToPath } from 'node:url';
|
|
18
|
+
|
|
19
|
+
/** A word for `sh`, whatever it holds: a space, a quote, a `$`. */
|
|
20
|
+
export const shellWord = (text: string): string => `'${text.replaceAll("'", "'\\''")}'`;
|
|
21
|
+
|
|
22
|
+
/** The line that opens pi in a project's directory, resuming a session when one is given: what the script runs, and what the owner is shown to run. */
|
|
23
|
+
export function piCommandLine(cwd: string, sessionFile: string | null, pi = 'pi'): string {
|
|
24
|
+
return `cd ${shellWord(cwd)} && ${pi === 'pi' ? 'pi' : shellWord(pi)}${sessionFile ? ` --session ${shellWord(sessionFile)}` : ''}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The script Terminal runs: go to the project, then become pi (so closing pi ends the window's process). */
|
|
28
|
+
export function openInPiScript(cwd: string, sessionFile: string | null, pi = 'pi'): string {
|
|
29
|
+
return `#!/bin/sh\ncd ${shellWord(cwd)} || exit 1\nexec ${pi === 'pi' ? 'pi' : shellWord(pi)}${sessionFile ? ` --session ${shellWord(sessionFile)}` : ''}\n`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const runnable = (path: string): boolean => { try { accessSync(path, constants.X_OK); return true; } catch { return false; } };
|
|
33
|
+
|
|
34
|
+
/** pi's command: the one on the PATH, else the one installed with ProjectKeeper; null when there is neither. */
|
|
35
|
+
export function findPi(pathVariable: string = process.env.PATH ?? '', installed: string = fileURLToPath(new URL('../../node_modules/.bin/pi', import.meta.url))): string | null {
|
|
36
|
+
for (const dir of pathVariable.split(delimiter)) if (dir && runnable(join(dir, 'pi'))) return 'pi';
|
|
37
|
+
return runnable(installed) ? installed : null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function openInPiOffWindows(cwd: string, sessionFile: string | null, platform: NodeJS.Platform = process.platform): { message: string } {
|
|
41
|
+
const yourself = (pi: string) => `run it yourself in a terminal: ${piCommandLine(cwd, sessionFile, pi)}`;
|
|
42
|
+
if (platform !== 'darwin') return { message: `Opening a terminal is not available on this system; ${yourself('pi')}` };
|
|
43
|
+
const pi = findPi();
|
|
44
|
+
if (!pi) return { message: `pi’s command was not found, neither on the PATH nor installed with ProjectKeeper, so nothing was opened; once it is installed, ${yourself('pi')}` };
|
|
45
|
+
try {
|
|
46
|
+
const script = join(mkdtempSync(join(tmpdir(), 'pk-open-in-pi-')), 'open-in-pi.command');
|
|
47
|
+
writeFileSync(script, openInPiScript(cwd, sessionFile, pi), { mode: 0o700 });
|
|
48
|
+
const asked = spawnSync('open', ['-a', 'Terminal', script], { encoding: 'utf8', timeout: 15_000, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
49
|
+
if (asked.error || asked.status !== 0) {
|
|
50
|
+
const why = asked.error?.message ?? ((asked.stderr || asked.stdout || '').trim().split('\n')[0] || `open ended with ${asked.status ?? asked.signal}`);
|
|
51
|
+
return { message: `Terminal could not be opened (${why}), so pi was not started; ${yourself(pi)}` };
|
|
52
|
+
}
|
|
53
|
+
return { message: `Asked Terminal to open pi in ${cwd}${sessionFile ? ' with the Keeper session' : ''}` };
|
|
54
|
+
} catch (e) {
|
|
55
|
+
return { message: `Could not open pi: ${(e as Error).message}; ${yourself(pi)}` };
|
|
56
|
+
}
|
|
57
|
+
}
|