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,741 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Project scope discovery (Spec §1.1, §3.7 stage 1, §6.7; CKC-04).
|
|
3
|
+
*
|
|
4
|
+
* From the locations the owner gave, work out what the project includes: directories,
|
|
5
|
+
* repositories, registered worktrees (also outside the directory), nested independent
|
|
6
|
+
* repositories, copies of other projects, archived or moved-out material with the reason
|
|
7
|
+
* its own README gives, and where the sessions for these paths live. Everything is derived
|
|
8
|
+
* from what is on disk; questions are raised only when the ambiguity would change the
|
|
9
|
+
* result (§3.9). Nothing here changes project content.
|
|
10
|
+
*
|
|
11
|
+
* Spec v2.8 adds (CKC-04 AC-1, AC-13–AC-17): each registered worktree measured against the trunk from git's records
|
|
12
|
+
* (./worktree.ts); what the project's own ignore rules leave out, listed by directory with the rule, and ignored
|
|
13
|
+
* documents put to the owner (./ignore.ts); third-party material and generated output nobody ignored, offered for the
|
|
14
|
+
* Keeper to judge (./candidates.ts); and what the Keeper, the project's rules and the owner then decide
|
|
15
|
+
* (./decisions.ts). What each item means for reading is in ./skip.ts, the one skip list.
|
|
16
|
+
*/
|
|
17
|
+
import { existsSync, readFileSync, readdirSync, statSync, type Dirent } from 'node:fs';
|
|
18
|
+
import { homedir } from 'node:os';
|
|
19
|
+
import { basename, dirname, isAbsolute, join, parse, relative } from 'node:path';
|
|
20
|
+
import type { ProjectRule, ScopeClassification, ScopeItem, ScopeJudgement, ScopeQuestion } from '../model/types.ts';
|
|
21
|
+
import type { ScopeRelation, SessionHost } from '../model/vocab.ts';
|
|
22
|
+
import { stableId } from '../model/ids.ts';
|
|
23
|
+
import { git, gitCommonDir, gitDir, gitRemotes, gitRootCommits, gitToplevel, gitWorktrees } from '../util/git.ts';
|
|
24
|
+
import { canonicalPath, expandHome, gitPathLimit, isWithin, nameForm, normalizePath, pathKey, relativeDisplay, samePath } from '../util/paths.ts';
|
|
25
|
+
import { locateSessionsForHomes, type LocatedSession } from '../sources/sessions/locate.ts';
|
|
26
|
+
import { sameSessionItem } from '../sources/sessions/scope.ts';
|
|
27
|
+
import { discoverToolchain, type ToolchainRoot } from './toolchain.ts';
|
|
28
|
+
import { classifyName, isDocumentPath, isSkippedName, isVendoredName, NOT_READ_KINDS } from './skip.ts';
|
|
29
|
+
import { ignoredEntries, predicateFrom, type IgnoreRule } from './ignore.ts';
|
|
30
|
+
import { measureWorktree, trunkOf, worktreeSentence } from './worktree.ts';
|
|
31
|
+
import { findCandidates, licenseIn, type Candidate } from './candidates.ts';
|
|
32
|
+
import { applyDecisions, ignoredQuestionId } from './decisions.ts';
|
|
33
|
+
|
|
34
|
+
export interface ReasonRef {
|
|
35
|
+
readonly path: string;
|
|
36
|
+
readonly headingPath: readonly string[];
|
|
37
|
+
readonly lineStart: number;
|
|
38
|
+
readonly lineEnd: number;
|
|
39
|
+
readonly excerpt: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface DiscoveredItem extends ScopeItem {
|
|
43
|
+
readonly reasonRef: ReasonRef | null;
|
|
44
|
+
readonly sessions?: readonly LocatedSession[];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface DiscoveryResult {
|
|
48
|
+
readonly items: readonly DiscoveredItem[];
|
|
49
|
+
readonly questions: readonly ScopeQuestion[];
|
|
50
|
+
readonly missingSourceKinds: readonly { readonly kind: string; readonly reason: string }[];
|
|
51
|
+
readonly roles: readonly string[];
|
|
52
|
+
readonly language: string;
|
|
53
|
+
/** §6.7 (B2) toolchain locations the project's own config points at; also allowed read roots (§3.1). */
|
|
54
|
+
readonly toolchain: readonly ToolchainRoot[];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface DiscoveryOptions {
|
|
58
|
+
readonly home?: string;
|
|
59
|
+
/** Answers the owner already gave (kept across rescans). */
|
|
60
|
+
readonly existingQuestions?: readonly ScopeQuestion[];
|
|
61
|
+
/** Items the owner added or removed by hand; kept as they are. */
|
|
62
|
+
readonly ownerItems?: readonly ScopeItem[];
|
|
63
|
+
/** B2: the ProjectKeeper home in use, so a toolchain location inside or above it is not used (§3.1). */
|
|
64
|
+
readonly projectKeeperHome?: string;
|
|
65
|
+
/** §1.15 the project's rules: the directories and branches a material rule names are marked with it (AC-1, AC-14). */
|
|
66
|
+
readonly rules?: readonly ProjectRule[];
|
|
67
|
+
/** §1.1 the Keeper's classifications and the owner's corrections (`pk_classify_scope`). */
|
|
68
|
+
readonly judgements?: readonly ScopeJudgement[];
|
|
69
|
+
/** Where a rule's source is, for the listing's reason (the asset store knows; discovery does not). */
|
|
70
|
+
readonly sourceLabel?: (sourceId: string) => string | null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const ARCHIVE_NAMES = /^(archive|archived|delete|deleted|discarded|superseded|trash|old|legacy)/i;
|
|
74
|
+
const REASON_FILES = ['README.md', 'RESTORE.md', 'readme.md', 'ARCHIVE.md', 'NOTES.md'];
|
|
75
|
+
const ROLE_NAMES = ['Main agent', 'Product architect', 'Execution orchestrator', 'Worker agent', 'QC reviewer'];
|
|
76
|
+
/** How many files an ignored directory is counted up to: enough to say what it holds, without walking a dependency tree to the end. */
|
|
77
|
+
const COUNT_LIMIT = 50_000;
|
|
78
|
+
|
|
79
|
+
function itemId(path: string, kind: string): string {
|
|
80
|
+
return stableId('scope', kind, pathKey(path));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** A session identity is structured data, not a relative pseudo-path resolved under the service's launch directory. */
|
|
84
|
+
function sessionItemId(host: SessionHost, cwd: string): string {
|
|
85
|
+
return stableId('scope', 'sessions', host, pathKey(cwd));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** First heading and first paragraph of a directory's explanatory file: the reason a folder exists. */
|
|
89
|
+
export function readReason(dir: string): ReasonRef | null {
|
|
90
|
+
for (const name of REASON_FILES) {
|
|
91
|
+
const file = join(dir, name);
|
|
92
|
+
if (!existsSync(file)) continue;
|
|
93
|
+
let text = '';
|
|
94
|
+
try { text = readFileSync(file, 'utf8'); } catch { continue; }
|
|
95
|
+
const lines = text.split(/\r?\n/);
|
|
96
|
+
const headingPath: string[] = [];
|
|
97
|
+
let start = -1;
|
|
98
|
+
let end = -1;
|
|
99
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
100
|
+
const line = lines[i]!;
|
|
101
|
+
if (/^#{1,6}\s/.test(line)) {
|
|
102
|
+
if (headingPath.length === 0) headingPath.push(line.replace(/^#+\s*/, '').trim());
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
if (line.trim() === '') { if (start >= 0) { end = i - 1; break; } continue; }
|
|
106
|
+
if (start < 0) start = i;
|
|
107
|
+
}
|
|
108
|
+
if (start < 0) continue;
|
|
109
|
+
if (end < 0) end = Math.min(lines.length - 1, start + 6);
|
|
110
|
+
const excerpt = lines.slice(start, end + 1).join('\n').slice(0, 600);
|
|
111
|
+
return { path: file, headingPath, lineStart: start + 1, lineEnd: end + 1, excerpt };
|
|
112
|
+
}
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function reasonText(ref: ReasonRef | null, fallback: string): string {
|
|
117
|
+
if (!ref) return fallback;
|
|
118
|
+
const first = ref.excerpt.split('\n').map((l) => l.replace(/^[-*>\s]+/, '').trim()).find((l) => l.length > 0) ?? '';
|
|
119
|
+
return `${basename(dirname(ref.path))}/${basename(ref.path)}: ${first.slice(0, 200)}`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** A directory's entries, each under the name names are kept in (`nameForm`): the listing of a Mac may give another Unicode form than git does. */
|
|
123
|
+
const entriesOf = (dir: string): Dirent[] => { try { return readdirSync(dir, { withFileTypes: true }).map((e) => { e.name = nameForm(e.name); return e; }); } catch { return []; } };
|
|
124
|
+
|
|
125
|
+
/** Subdirectories, not following links (a link is listed by the candidates walk, never entered). */
|
|
126
|
+
function listDirs(dir: string): string[] {
|
|
127
|
+
return entriesOf(dir).filter((e) => e.isDirectory()).map((e) => e.name);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Repositories below `root` (depth-limited). A parent's ignore rules cannot hide a child's own `.git`. */
|
|
131
|
+
function findNestedRepos(root: string, maxDepth = 4): string[] {
|
|
132
|
+
const out: string[] = [];
|
|
133
|
+
const walk = (dir: string, depth: number) => {
|
|
134
|
+
if (depth > maxDepth) return;
|
|
135
|
+
for (const name of listDirs(dir)) {
|
|
136
|
+
if (name === '.git' || name === '.worktrees') continue;
|
|
137
|
+
const full = join(dir, name);
|
|
138
|
+
const dotGit = join(full, '.git');
|
|
139
|
+
if (existsSync(dotGit)) {
|
|
140
|
+
// A repository's own location, like every location, in the file system's spelling (the names a listing gives
|
|
141
|
+
// are composed on a Mac; the directory may be stored otherwise, and an agent that worked there recorded that).
|
|
142
|
+
out.push(canonicalPath(full));
|
|
143
|
+
continue; // a repository's inside is its own business
|
|
144
|
+
}
|
|
145
|
+
if (isSkippedName(name) && !isVendoredName(name)) continue; // each library in a vendored directory is told apart
|
|
146
|
+
walk(full, depth + 1);
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
walk(root, 0);
|
|
150
|
+
return out;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** A tracked gitlink is a submodule, not a nested independent repository. */
|
|
154
|
+
function isGitlink(root: string, nested: string): boolean {
|
|
155
|
+
const rel = relativeDisplay(root, nested);
|
|
156
|
+
const listed = git(root, ['ls-files', '--stage', '--', rel]);
|
|
157
|
+
return listed.ok && /^160000\s/m.test(listed.out);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** A `.git` *file* points at a worktree of some repository; return that repository's common dir. */
|
|
161
|
+
function linkedWorktreeTarget(dir: string): string | null {
|
|
162
|
+
const dotGit = join(dir, '.git');
|
|
163
|
+
try {
|
|
164
|
+
if (!statSync(dotGit).isFile()) return null;
|
|
165
|
+
const text = readFileSync(dotGit, 'utf8');
|
|
166
|
+
const m = /^gitdir:\s*(.+)$/m.exec(text);
|
|
167
|
+
if (!m?.[1]) return null;
|
|
168
|
+
const target = normalizePath(join(dir, m[1].trim()));
|
|
169
|
+
// <repo>/.git/worktrees/<name> → <repo>/.git
|
|
170
|
+
const idx = target.toLowerCase().lastIndexOf(`${'\\'}.git${'\\'}worktrees${'\\'}`);
|
|
171
|
+
const idx2 = target.toLowerCase().lastIndexOf('/.git/worktrees/');
|
|
172
|
+
if (idx >= 0) return target.slice(0, idx + 5);
|
|
173
|
+
if (idx2 >= 0) return target.slice(0, idx2 + 5);
|
|
174
|
+
return null;
|
|
175
|
+
} catch {
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function sameDriveSibling(path: string): string | null {
|
|
181
|
+
// D:\trials\ledger → D:\ledger
|
|
182
|
+
const root = parse(path).root;
|
|
183
|
+
const candidate = join(root, basename(path));
|
|
184
|
+
return !samePath(candidate, path) && existsSync(candidate) ? normalizePath(candidate) : null;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Copy detection: a sibling copy-log naming the source, or another repository with the same root commit. */
|
|
188
|
+
function detectCopy(dir: string, isRepo: boolean): { source: string; sessionStoreRoot?: string; reason: ReasonRef | null; how: string } | null {
|
|
189
|
+
const log = `${dir}.copy-log.txt`;
|
|
190
|
+
if (existsSync(log)) {
|
|
191
|
+
try {
|
|
192
|
+
const text = readFileSync(log, 'utf8');
|
|
193
|
+
const sourceLine = /^(?:来源|source|from)\s*[::]\s*(.+?)\s*$/im.exec(text)?.[1];
|
|
194
|
+
// Older logs also say "from D:\\..." without a delimiter; keep recognising those.
|
|
195
|
+
const source = sourceLine && isAbsolute(sourceLine) ? sourceLine
|
|
196
|
+
: /(?:来源|source|from)[::]?\s*([A-Za-z]:\\[^\s\r\n]+|\/[^\s\r\n]+)/i.exec(text)?.[1];
|
|
197
|
+
const sessionRoot = /^(?:sessions|会话)\s*[::]\s*(.+?)\s*$/im.exec(text)?.[1];
|
|
198
|
+
const sessionStoreRoot = sessionRoot && isAbsolute(sessionRoot) ? canonicalPath(sessionRoot) : undefined;
|
|
199
|
+
const lines = text.split(/\r?\n/);
|
|
200
|
+
const ref: ReasonRef = { path: log, headingPath: [], lineStart: 1, lineEnd: Math.min(lines.length, 5), excerpt: lines.slice(0, 5).join('\n') };
|
|
201
|
+
if (source && isAbsolute(source)) return { source: canonicalPath(source), sessionStoreRoot, reason: ref, how: 'copy log names the source' };
|
|
202
|
+
return { source: '', sessionStoreRoot, reason: ref, how: 'copy log present' };
|
|
203
|
+
} catch { /* fall through */ }
|
|
204
|
+
}
|
|
205
|
+
const sibling = sameDriveSibling(dir);
|
|
206
|
+
if (sibling && isRepo && existsSync(join(sibling, '.git'))) {
|
|
207
|
+
const a = gitRootCommits(dir);
|
|
208
|
+
const b = gitRootCommits(sibling);
|
|
209
|
+
if (a.length > 0 && a.join(',') === b.join(',')) {
|
|
210
|
+
return { source: sibling, reason: null, how: 'same root commit as the same-named repository' };
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function detectRoles(root: string): string[] {
|
|
217
|
+
const found = new Set<string>();
|
|
218
|
+
const scan = (dir: string, depth: number) => {
|
|
219
|
+
if (depth > 2) return;
|
|
220
|
+
let names: string[] = [];
|
|
221
|
+
try { names = readdirSync(dir); } catch { return; }
|
|
222
|
+
for (const name of names) {
|
|
223
|
+
const full = join(dir, name);
|
|
224
|
+
let st;
|
|
225
|
+
try { st = statSync(full); } catch { continue; }
|
|
226
|
+
if (st.isDirectory()) { if (!isSkippedName(name) && !ARCHIVE_NAMES.test(name)) scan(full, depth + 1); continue; }
|
|
227
|
+
if (!name.endsWith('.md') || st.size > 400_000) continue;
|
|
228
|
+
let text = '';
|
|
229
|
+
try { text = readFileSync(full, 'utf8').slice(0, 20_000); } catch { continue; }
|
|
230
|
+
for (const role of ROLE_NAMES) if (text.includes(role)) found.add(role);
|
|
231
|
+
}
|
|
232
|
+
};
|
|
233
|
+
scan(root, 0);
|
|
234
|
+
return [...found];
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function detectLanguage(root: string): string {
|
|
238
|
+
let cjk = 0;
|
|
239
|
+
let total = 0;
|
|
240
|
+
const scan = (dir: string, depth: number) => {
|
|
241
|
+
if (depth > 2 || total > 200_000) return;
|
|
242
|
+
let names: string[] = [];
|
|
243
|
+
try { names = readdirSync(dir); } catch { return; }
|
|
244
|
+
for (const name of names) {
|
|
245
|
+
const full = join(dir, name);
|
|
246
|
+
let st;
|
|
247
|
+
try { st = statSync(full); } catch { continue; }
|
|
248
|
+
if (st.isDirectory()) { if (!isSkippedName(name)) scan(full, depth + 1); continue; }
|
|
249
|
+
if (!name.endsWith('.md')) continue;
|
|
250
|
+
let text = '';
|
|
251
|
+
try { text = readFileSync(full, 'utf8').slice(0, 20_000); } catch { continue; }
|
|
252
|
+
total += text.length;
|
|
253
|
+
cjk += (text.match(/[\u4e00-\u9fff]/g) ?? []).length;
|
|
254
|
+
}
|
|
255
|
+
};
|
|
256
|
+
scan(root, 0);
|
|
257
|
+
return total > 0 && cjk / total > 0.08 ? 'zh' : 'en';
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Does the material itself show Cursor use? Either Cursor's own files, or the project's instruction,
|
|
262
|
+
* status and handoff documents naming Cursor (as an agent, so the capitalised word only).
|
|
263
|
+
*/
|
|
264
|
+
function cursorEvidence(location: string): string | null {
|
|
265
|
+
if (existsSync(join(location, '.cursor')) || existsSync(join(location, '.cursorrules'))) return '.cursor/ or .cursorrules is present';
|
|
266
|
+
const candidates = ['AGENTS.md', 'CLAUDE.md', 'README.md', 'HANDOFF.md', 'STATUS.md'].map((f) => join(location, f));
|
|
267
|
+
const docs = join(location, 'docs');
|
|
268
|
+
try {
|
|
269
|
+
if (existsSync(docs)) for (const f of readdirSync(docs)) if (/\.md$/i.test(f)) candidates.push(join(docs, f));
|
|
270
|
+
} catch { /* unreadable docs directory: nothing to add */ }
|
|
271
|
+
for (const file of candidates) {
|
|
272
|
+
try {
|
|
273
|
+
if (!existsSync(file) || statSync(file).size > 1_000_000) continue;
|
|
274
|
+
if (/\bCursor\b/.test(readFileSync(file, 'utf8'))) return `${relative(location, file)} mentions Cursor`;
|
|
275
|
+
} catch { /* unreadable file: skip */ }
|
|
276
|
+
}
|
|
277
|
+
return null;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// ───────────────────────── what the ignore rules leave out (AC-17) ─────────────────────────
|
|
281
|
+
|
|
282
|
+
const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? '' : 's'}`;
|
|
283
|
+
|
|
284
|
+
/** Files and documents under a directory the ignore rules leave out (not following links). */
|
|
285
|
+
function countFiles(dir: string, root: string): { files: number; documents: number; names: string[]; capped: boolean } {
|
|
286
|
+
let files = 0;
|
|
287
|
+
let documents = 0;
|
|
288
|
+
const names: string[] = [];
|
|
289
|
+
const walk = (d: string) => {
|
|
290
|
+
for (const e of entriesOf(d)) {
|
|
291
|
+
if (files >= COUNT_LIMIT) return;
|
|
292
|
+
if (e.name === '.git') continue;
|
|
293
|
+
const full = join(d, e.name);
|
|
294
|
+
if (e.isDirectory()) { walk(full); continue; }
|
|
295
|
+
if (!e.isFile()) continue;
|
|
296
|
+
files += 1;
|
|
297
|
+
if (isDocumentPath(e.name)) { documents += 1; if (names.length < 6) names.push(relativeDisplay(root, full)); }
|
|
298
|
+
}
|
|
299
|
+
};
|
|
300
|
+
walk(dir);
|
|
301
|
+
return { files, documents, names, capped: files >= COUNT_LIMIT };
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const ruleWords = (rule: IgnoreRule | null) => (rule ? `${rule.file} line ${rule.line}: ${rule.pattern}` : 'git’s ignore rules');
|
|
305
|
+
const programClass = (kind: string, evidence: readonly string[]): ScopeClassification => ({ by: 'program', basis: 'Inferred', kind, evidence, sourceIds: [], ruleId: null, jobId: null, at: null });
|
|
306
|
+
|
|
307
|
+
// ───────────────────────── third-party material and generated output (AC-13) ─────────────────────────
|
|
308
|
+
|
|
309
|
+
function candidateSentence(relation: 'Generated' | 'Third-party material', kind: string, evidence: readonly string[], history = false): string {
|
|
310
|
+
const then = relation === 'Generated' ? 'not organized'
|
|
311
|
+
: NOT_READ_KINDS.has(kind) ? 'nothing in it is read'
|
|
312
|
+
: `its documents are read as Reference only; its code${history ? ' and its history are' : ' is'} not organized`;
|
|
313
|
+
return `${relation} (candidate, not yet judged by the Keeper; Inferred): ${evidence.join('; ')} · ${then}`;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** The part of discovery that reads the project: disk and git, nothing decided yet. */
|
|
317
|
+
export function discoverBase(
|
|
318
|
+
project: { readonly id: string; readonly name: string; readonly locations: readonly string[] },
|
|
319
|
+
options: DiscoveryOptions = {},
|
|
320
|
+
): DiscoveryBase {
|
|
321
|
+
const home = options.home ?? homedir();
|
|
322
|
+
const items: DiscoveredItem[] = [];
|
|
323
|
+
const questions: ScopeQuestion[] = [];
|
|
324
|
+
const missing: { kind: string; reason: string }[] = [];
|
|
325
|
+
const seen = new Set<string>();
|
|
326
|
+
const sessionCwds = new Set<string>();
|
|
327
|
+
const sessionHomes = new Map<string, string>();
|
|
328
|
+
const push = (item: DiscoveredItem) => {
|
|
329
|
+
// A session item is one per host and working directory. They all sit at the host's log root, so telling them apart
|
|
330
|
+
// by path kept only the first directory's item, and every other directory's sessions were filed under it.
|
|
331
|
+
const key = item.category === 'Session source' && item.sessionCwd
|
|
332
|
+
? `${item.category}:${item.sessionHost}:${pathKey(item.sessionCwd)}`
|
|
333
|
+
: `${item.category}:${pathKey(item.path)}`;
|
|
334
|
+
if (seen.has(key)) return;
|
|
335
|
+
seen.add(key);
|
|
336
|
+
items.push(item);
|
|
337
|
+
};
|
|
338
|
+
const existingQuestion = (id: string) => options.existingQuestions?.find((q) => q.id === id);
|
|
339
|
+
const base = { reasonSourceIds: [] as string[], sessionHost: null, readOnly: false, copyOf: null, worktreeOf: null, missing: null, addedBy: 'keeper' as const, reasonRef: null };
|
|
340
|
+
// The locations in the file system's own spelling, which is how git reports the repository and its worktrees: a
|
|
341
|
+
// location given through a short (8.3) name, a junction, a subst drive or in another case is the same directory.
|
|
342
|
+
const given = project.locations.map(canonicalPath);
|
|
343
|
+
const locations = given.filter((l) => existsSync(l));
|
|
344
|
+
|
|
345
|
+
/** List what the ignore rules of the git work tree at `root` leave out; returns the predicate the other walks prune with. */
|
|
346
|
+
const listIgnored = (root: string, keep: readonly string[]): ((rel: string) => boolean) | null => {
|
|
347
|
+
const entries = ignoredEntries(root);
|
|
348
|
+
if (!entries) return null;
|
|
349
|
+
const relOf = (p: string) => relativeDisplay(root, p);
|
|
350
|
+
const listDir = (full: string, rule: IgnoreRule | null) => {
|
|
351
|
+
const count = countFiles(full, root);
|
|
352
|
+
const cls = classifyName(basename(full));
|
|
353
|
+
const docsNote = count.documents ? `, ${count.documents} of them ${count.documents === 1 ? 'a document' : 'documents'} (${count.names.join(', ')}${count.documents > count.names.length ? ', …' : ''})` : '';
|
|
354
|
+
const ask = count.documents > 0 && !cls;
|
|
355
|
+
push({
|
|
356
|
+
...base, id: itemId(full, 'dir'), path: full, category: 'Directory', relation: 'Excluded', versionControl: 'git',
|
|
357
|
+
reason: `Ignored by the project's ignore rules: ${ruleWords(rule)} · ${count.capped ? `more than ${COUNT_LIMIT} files` : plural(count.files, 'file')}${docsNote}${cls ? ` · ${cls.evidence}` : ''} · not organized${ask ? '; the owner decides whether to include the documents' : ''}`,
|
|
358
|
+
ignoredBy: { file: rule?.file ?? 'git', line: rule?.line ?? 0, pattern: rule?.pattern ?? '', files: count.files, documents: count.documents, documentNames: count.names },
|
|
359
|
+
classification: cls ? programClass(cls.kind, [cls.evidence]) : null,
|
|
360
|
+
});
|
|
361
|
+
if (ask) raiseIgnoredQuestion(full, relOf(full), rule, count.documents, count.names);
|
|
362
|
+
};
|
|
363
|
+
const listFiles = (dirRel: string, rule: IgnoreRule | null, rels: readonly string[]) => {
|
|
364
|
+
const seg = (rule?.pattern ?? '').replace(/\/+$/, '').split('/').pop() ?? '';
|
|
365
|
+
const groups = /[*?[]/.test(seg) ? [rels] : rels.map((r) => [r]);
|
|
366
|
+
for (const group of groups) {
|
|
367
|
+
const path = group.length === 1 && !/[*?[]/.test(seg) ? join(root, group[0]!) : join(root, dirRel === '.' ? '' : dirRel, seg);
|
|
368
|
+
const docs = group.filter((r) => isDocumentPath(r));
|
|
369
|
+
push({
|
|
370
|
+
...base, id: itemId(path, 'dir'), path: normalizePath(path), category: 'Directory', relation: 'Excluded', versionControl: 'git',
|
|
371
|
+
reason: `Ignored by the project's ignore rules: ${ruleWords(rule)} · ${plural(group.length, 'file')}${dirRel === '.' ? '' : ` in ${dirRel}/`}${docs.length ? `, ${docs.length} of them ${docs.length === 1 ? 'a document' : 'documents'} (${docs.slice(0, 6).join(', ')})` : ''} · not organized${docs.length ? '; the owner decides whether to include the documents' : ''}`,
|
|
372
|
+
ignoredBy: { file: rule?.file ?? 'git', line: rule?.line ?? 0, pattern: rule?.pattern ?? '', files: group.length, documents: docs.length, documentNames: docs.slice(0, 6), paths: group },
|
|
373
|
+
});
|
|
374
|
+
if (docs.length) raiseIgnoredQuestion(normalizePath(path), relOf(path), rule, docs.length, docs.slice(0, 6));
|
|
375
|
+
}
|
|
376
|
+
};
|
|
377
|
+
// An ignored directory that holds registered worktrees: the worktrees are measured on their own; the rest is listed.
|
|
378
|
+
const drill = (dir: string, rule: IgnoreRule | null) => {
|
|
379
|
+
for (const e of entriesOf(dir)) {
|
|
380
|
+
const full = normalizePath(join(dir, e.name));
|
|
381
|
+
if (keep.some((k) => samePath(k, full))) continue;
|
|
382
|
+
if (keep.some((k) => isWithin(full, k))) { drill(full, rule); continue; }
|
|
383
|
+
if (e.isDirectory()) listDir(full, rule);
|
|
384
|
+
else if (e.isFile()) listFiles(relOf(dirname(full)) || '.', rule, [relOf(full)]);
|
|
385
|
+
}
|
|
386
|
+
};
|
|
387
|
+
const scattered = new Map<string, { dir: string; rule: IgnoreRule | null; rels: string[] }>();
|
|
388
|
+
for (const e of entries) {
|
|
389
|
+
const full = normalizePath(join(root, e.rel));
|
|
390
|
+
if (keep.some((k) => samePath(k, full))) continue;
|
|
391
|
+
if (e.dir) {
|
|
392
|
+
if (keep.some((k) => isWithin(full, k))) drill(full, e.rule);
|
|
393
|
+
else listDir(full, e.rule);
|
|
394
|
+
continue;
|
|
395
|
+
}
|
|
396
|
+
const dir = dirname(e.rel) === '.' ? '.' : dirname(e.rel).split('\\').join('/');
|
|
397
|
+
const key = `${dir}|${e.rule?.file}|${e.rule?.line}|${e.rule?.pattern}`;
|
|
398
|
+
const group = scattered.get(key) ?? { dir, rule: e.rule, rels: [] };
|
|
399
|
+
group.rels.push(e.rel);
|
|
400
|
+
scattered.set(key, group);
|
|
401
|
+
}
|
|
402
|
+
for (const g of scattered.values()) listFiles(g.dir, g.rule, g.rels);
|
|
403
|
+
return predicateFrom(entries);
|
|
404
|
+
};
|
|
405
|
+
const raiseIgnoredQuestion = (path: string, rel: string, rule: IgnoreRule | null, documents: number, names: readonly string[]) => {
|
|
406
|
+
const id = ignoredQuestionId(path);
|
|
407
|
+
questions.push(existingQuestion(id) ?? {
|
|
408
|
+
id,
|
|
409
|
+
question: `${rel} is left out by the project's ignore rules (${ruleWords(rule)}) but holds ${plural(documents, 'document')}. Should they be organized as project material?`,
|
|
410
|
+
whyItMatters: 'What the ignore rules leave out is not organized. If these documents carry the project’s intent — notes, plans, decisions — leaving them out loses it.',
|
|
411
|
+
clues: [ruleWords(rule), ...names],
|
|
412
|
+
options: ['Include them', 'Leave them out'],
|
|
413
|
+
answer: null,
|
|
414
|
+
});
|
|
415
|
+
};
|
|
416
|
+
const listCandidates = (root: string, ignored: ((rel: string) => boolean) | null, separate: readonly string[], isGit: boolean) => {
|
|
417
|
+
for (const c of findCandidates(root, { locations, ignored, separate, git: isGit })) pushCandidate(c, isGit);
|
|
418
|
+
};
|
|
419
|
+
const pushCandidate = (c: Candidate, isGit: boolean) => push({
|
|
420
|
+
...base, id: itemId(c.path, 'dir'), path: c.path, category: 'Directory', relation: c.relation, versionControl: isGit ? 'git' : 'none',
|
|
421
|
+
reason: candidateSentence(c.relation, c.kind, c.evidence), classification: programClass(c.kind, c.evidence),
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
for (const location of given) {
|
|
425
|
+
if (!existsSync(location)) {
|
|
426
|
+
push({
|
|
427
|
+
id: itemId(location, 'dir'), path: location, category: 'Directory', relation: 'Main project',
|
|
428
|
+
reason: 'Owner-given location; it does not exist or cannot be read', reasonSourceIds: [], sessionHost: null,
|
|
429
|
+
readOnly: false, copyOf: null, worktreeOf: null, versionControl: 'unknown',
|
|
430
|
+
missing: { reason: 'Path does not exist or is not readable' }, addedBy: 'owner', reasonRef: null,
|
|
431
|
+
});
|
|
432
|
+
continue;
|
|
433
|
+
}
|
|
434
|
+
sessionCwds.add(location);
|
|
435
|
+
const toplevel = gitToplevel(location);
|
|
436
|
+
const isRepo = toplevel !== null && samePath(toplevel, location);
|
|
437
|
+
const insideRepo = toplevel !== null && !isRepo && isWithin(toplevel, location);
|
|
438
|
+
// A repository git cannot open because its path is too long is said to be one, with the limit and what to do; it
|
|
439
|
+
// is read as its files, and must not pass for a directory nobody put under version control.
|
|
440
|
+
const tooLong = toplevel === null ? gitPathLimit(location) : null;
|
|
441
|
+
if (tooLong) missing.push({ kind: 'Git history', reason: `${tooLong} (${location})` });
|
|
442
|
+
// An answered copy question is the owner's word on the copy's source and outlives rescans.
|
|
443
|
+
const answered = options.existingQuestions?.find((q) => q.id === stableId('scopeq', 'copy-source', pathKey(location)) && q.answer)?.answer?.text.trim() ?? null;
|
|
444
|
+
const detected = detectCopy(location, isRepo);
|
|
445
|
+
const typedSource = answered ? expandHome(answered) : null; // the owner may have written it with `~`
|
|
446
|
+
const namedSource = typedSource && /^[A-Za-z]:\\|^\//.test(typedSource) ? canonicalPath(typedSource) : null;
|
|
447
|
+
const copy = answered && /not a copy/i.test(answered) ? null
|
|
448
|
+
: namedSource ? { source: namedSource, sessionStoreRoot: detected?.sessionStoreRoot, reason: detected?.reason ?? null, how: detected ? `${detected.how}; the owner named the source` : 'the owner named the source' }
|
|
449
|
+
: detected;
|
|
450
|
+
|
|
451
|
+
push({
|
|
452
|
+
id: itemId(location, isRepo ? 'repo' : 'dir'), path: location,
|
|
453
|
+
category: isRepo ? 'Repository' : 'Directory',
|
|
454
|
+
relation: copy ? 'Copy of another project' : 'Main project',
|
|
455
|
+
reason: copy
|
|
456
|
+
? `Copy of ${copy.source || 'another project'} (${copy.how}); the original stays read-only and is not merged in`
|
|
457
|
+
: isRepo ? 'Owner-given location; git repository'
|
|
458
|
+
: insideRepo ? `Owner-given location; a subdirectory of the repository at ${toplevel}`
|
|
459
|
+
: tooLong ? `Owner-given location; read as its files only. ${tooLong}`
|
|
460
|
+
: 'Owner-given location; no version control (the project is read as its files and running processes)',
|
|
461
|
+
reasonSourceIds: [], sessionHost: null, readOnly: false, copyOf: copy?.source || null, worktreeOf: null,
|
|
462
|
+
versionControl: isRepo || insideRepo ? 'git' : 'none', missing: null, addedBy: 'owner',
|
|
463
|
+
reasonRef: copy?.reason ?? null,
|
|
464
|
+
});
|
|
465
|
+
if (copy?.source) {
|
|
466
|
+
sessionCwds.add(copy.source);
|
|
467
|
+
if (copy.sessionStoreRoot) sessionHomes.set(pathKey(copy.source), copy.sessionStoreRoot);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// Registered worktrees of the main repository, wherever they live, each measured against the trunk (E60).
|
|
471
|
+
const worktreePaths: string[] = [];
|
|
472
|
+
if (isRepo) {
|
|
473
|
+
const trunk = trunkOf(location);
|
|
474
|
+
for (const wt of gitWorktrees(location)) {
|
|
475
|
+
if (samePath(wt.path, location)) continue;
|
|
476
|
+
worktreePaths.push(wt.path);
|
|
477
|
+
const inside = isWithin(location, wt.path);
|
|
478
|
+
const present = existsSync(wt.path);
|
|
479
|
+
const measured = present ? measureWorktree(wt.path, location, trunk, { head: wt.head, branch: wt.branch }) : null;
|
|
480
|
+
push({
|
|
481
|
+
id: itemId(wt.path, 'worktree'), path: wt.path, category: 'Worktree', relation: 'Worktree of main repo',
|
|
482
|
+
reason: `Registered worktree of ${location}${inside ? '' : ' (outside the project directory)'}${wt.branch ? `, branch ${wt.branch}` : wt.detached ? ', detached' : ''}${present ? '' : '; path missing on disk'}${measured ? ` · ${worktreeSentence(measured)}` : ''}`,
|
|
483
|
+
reasonSourceIds: [], sessionHost: null, readOnly: false, copyOf: null, worktreeOf: location,
|
|
484
|
+
versionControl: 'git', missing: present ? null : { reason: 'Registered but not present on disk' },
|
|
485
|
+
addedBy: 'keeper', reasonRef: null, worktree: measured,
|
|
486
|
+
});
|
|
487
|
+
if (present) sessionCwds.add(wt.path);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// Find independent repositories before applying the parent's ignore rules. Their own rules govern their files.
|
|
492
|
+
// One that lies inside a directory the parent ignores is not the project's — a test's temporary copies, a runtime
|
|
493
|
+
// folder, build output — and stays out with that directory. One whose own root is what the parent ignores, the usual
|
|
494
|
+
// way to keep a separate repository out of the parent's history (ContextKeeper's own `app/`), comes in. The same
|
|
495
|
+
// rule holds for any project (D98): the live ContextKeeper keeps demo repositories under ignored temp/ folders.
|
|
496
|
+
const parentIgnored = isRepo || insideRepo ? ignoredEntries(location) : null;
|
|
497
|
+
const inIgnoredDir = parentIgnored ? predicateFrom(parentIgnored) : null;
|
|
498
|
+
const nestedPaths = findNestedRepos(location).filter((nested) => !isGitlink(location, nested)).filter((nested) => {
|
|
499
|
+
if (!inIgnoredDir) return true;
|
|
500
|
+
const parentRel = relativeDisplay(location, dirname(nested));
|
|
501
|
+
return parentRel === '' || !inIgnoredDir(parentRel);
|
|
502
|
+
});
|
|
503
|
+
// What the project's own ignore rules leave out (none without version control).
|
|
504
|
+
const ignored = isRepo || insideRepo ? listIgnored(location, [...worktreePaths, ...nestedPaths]) : null;
|
|
505
|
+
|
|
506
|
+
// Nested independent repositories keep their own history; linked worktrees of other repos are experiments.
|
|
507
|
+
const mainCommon = isRepo ? gitCommonDir(location) : null;
|
|
508
|
+
const separate: string[] = [...worktreePaths];
|
|
509
|
+
for (const nested of nestedPaths) {
|
|
510
|
+
separate.push(nested);
|
|
511
|
+
const target = linkedWorktreeTarget(nested);
|
|
512
|
+
if (target) {
|
|
513
|
+
const ofMain = mainCommon !== null && samePath(target, mainCommon);
|
|
514
|
+
if (ofMain) continue; // already listed from `git worktree list`
|
|
515
|
+
push({
|
|
516
|
+
id: itemId(nested, 'worktree'), path: nested, category: 'Worktree', relation: 'Experiment',
|
|
517
|
+
reason: `Linked worktree whose .git points at ${target}; it belongs to that repository, not to the main history`,
|
|
518
|
+
reasonSourceIds: [], sessionHost: null, readOnly: false, copyOf: null, worktreeOf: target,
|
|
519
|
+
versionControl: 'git', missing: null, addedBy: 'keeper', reasonRef: null,
|
|
520
|
+
});
|
|
521
|
+
continue;
|
|
522
|
+
}
|
|
523
|
+
const nestedCommon = gitCommonDir(nested);
|
|
524
|
+
const nestedDir = gitDir(nested);
|
|
525
|
+
const isLinked = nestedCommon !== null && nestedDir !== null && !samePath(nestedCommon, nestedDir);
|
|
526
|
+
if (isLinked && mainCommon && nestedCommon && samePath(nestedCommon, mainCommon)) continue;
|
|
527
|
+
const remotes = gitRemotes(nested);
|
|
528
|
+
const upstream = remotes.map((l) => l.split(/\s+/)[1]).find(Boolean) ?? null;
|
|
529
|
+
const license = licenseIn(nested);
|
|
530
|
+
if (upstream && license) {
|
|
531
|
+
// Its own repository with an upstream and a license of its own: somebody else's code, distributed with the project.
|
|
532
|
+
const evidence = [`its own git repository with upstream ${upstream}`, `license file ${license}`];
|
|
533
|
+
push({
|
|
534
|
+
id: itemId(nested, 'repo'), path: nested, category: 'Repository', relation: 'Third-party material',
|
|
535
|
+
reason: candidateSentence('Third-party material', 'upstream repository', evidence, true),
|
|
536
|
+
reasonSourceIds: [], sessionHost: null, readOnly: false, copyOf: null, worktreeOf: null,
|
|
537
|
+
versionControl: 'git', missing: null, addedBy: 'keeper', reasonRef: null, classification: programClass('upstream repository', evidence),
|
|
538
|
+
});
|
|
539
|
+
continue;
|
|
540
|
+
}
|
|
541
|
+
push({
|
|
542
|
+
id: itemId(nested, 'repo'), path: nested, category: 'Repository', relation: 'Nested repository',
|
|
543
|
+
reason: `Has its own .git, so it belongs to its own repository and uses its own ignore rules rather than the parent's${remotes.length === 0 ? ' (no remote)' : ''}`,
|
|
544
|
+
reasonSourceIds: [], sessionHost: null, readOnly: false, copyOf: null, worktreeOf: null,
|
|
545
|
+
versionControl: 'git', missing: null, addedBy: 'keeper', reasonRef: null,
|
|
546
|
+
});
|
|
547
|
+
sessionCwds.add(nested);
|
|
548
|
+
// A nested repository's registered worktrees belong to it, wherever they live (§1.1), measured against its trunk.
|
|
549
|
+
const nestedTrunk = trunkOf(nested);
|
|
550
|
+
const nestedWorktrees: string[] = [];
|
|
551
|
+
for (const wt of gitWorktrees(nested)) {
|
|
552
|
+
if (samePath(wt.path, nested)) continue;
|
|
553
|
+
nestedWorktrees.push(wt.path);
|
|
554
|
+
separate.push(wt.path);
|
|
555
|
+
const measured = existsSync(wt.path) ? measureWorktree(wt.path, nested, nestedTrunk, { head: wt.head, branch: wt.branch }) : null;
|
|
556
|
+
push({
|
|
557
|
+
id: itemId(wt.path, 'worktree'), path: wt.path, category: 'Worktree', relation: 'Worktree of main repo',
|
|
558
|
+
reason: `Registered worktree of the nested repository ${nested}${isWithin(location, wt.path) ? '' : ' (outside the project directory)'}${wt.branch ? `, branch ${wt.branch}` : wt.detached ? ', detached' : ''}${measured ? ` · ${worktreeSentence(measured)}` : ''}`,
|
|
559
|
+
reasonSourceIds: [], sessionHost: null, readOnly: false, copyOf: null, worktreeOf: nested,
|
|
560
|
+
versionControl: 'git', missing: existsSync(wt.path) ? null : { reason: 'Registered but not present on disk' }, addedBy: 'keeper', reasonRef: null, worktree: measured,
|
|
561
|
+
});
|
|
562
|
+
sessionCwds.add(wt.path);
|
|
563
|
+
}
|
|
564
|
+
const nestedIgnored = listIgnored(nested, nestedWorktrees);
|
|
565
|
+
listCandidates(nested, nestedIgnored, nestedWorktrees, true);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// Third-party material and generated output nobody ignored: offered for the Keeper to judge.
|
|
569
|
+
listCandidates(location, ignored, separate, isRepo || insideRepo);
|
|
570
|
+
|
|
571
|
+
// Archived or moved-out material: listed with the reason its own file gives. Looked for at
|
|
572
|
+
// the top level and one level down (e.g. `design/archive/`).
|
|
573
|
+
const relOf = (p: string) => relativeDisplay(location, p);
|
|
574
|
+
const candidates: { name: string; full: string }[] = [];
|
|
575
|
+
for (const name of listDirs(location)) {
|
|
576
|
+
const full = normalizePath(join(location, name));
|
|
577
|
+
if (isSkippedName(name) || ignored?.(relOf(full))) continue;
|
|
578
|
+
candidates.push({ name, full });
|
|
579
|
+
if (!ARCHIVE_NAMES.test(name) && !existsSync(join(full, '.git'))) {
|
|
580
|
+
for (const sub of listDirs(full)) {
|
|
581
|
+
const subFull = normalizePath(join(full, sub));
|
|
582
|
+
if (!isSkippedName(sub) && !ignored?.(relOf(subFull))) candidates.push({ name: sub, full: subFull });
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
for (const { name, full } of candidates) {
|
|
587
|
+
if (!ARCHIVE_NAMES.test(name)) continue;
|
|
588
|
+
const ref = readReason(full);
|
|
589
|
+
const movedOut = /^(delete|deleted|trash)/i.test(name);
|
|
590
|
+
push({
|
|
591
|
+
id: itemId(full, 'dir'), path: full, category: 'Directory', relation: movedOut ? 'Excluded' : 'Main project',
|
|
592
|
+
reason: reasonText(ref, movedOut
|
|
593
|
+
? `${name}/ holds material the owner moved out of the active project`
|
|
594
|
+
: `${name}/ holds archived history of this project`),
|
|
595
|
+
reasonSourceIds: [], sessionHost: null, readOnly: false, copyOf: null, worktreeOf: null,
|
|
596
|
+
versionControl: 'unknown', missing: null, addedBy: 'keeper', reasonRef: ref,
|
|
597
|
+
});
|
|
598
|
+
// Archives often contain one more level of dated folders with their own README.
|
|
599
|
+
for (const sub of listDirs(full)) {
|
|
600
|
+
const subFull = normalizePath(join(full, sub));
|
|
601
|
+
const subRef = readReason(subFull);
|
|
602
|
+
if (!subRef) continue;
|
|
603
|
+
push({
|
|
604
|
+
id: itemId(subFull, 'dir'), path: subFull, category: 'Directory', relation: movedOut ? 'Excluded' : 'Main project',
|
|
605
|
+
reason: reasonText(subRef, `${name}/${sub}/`), reasonSourceIds: [], sessionHost: null, readOnly: false,
|
|
606
|
+
copyOf: null, worktreeOf: null, versionControl: 'unknown', missing: null, addedBy: 'keeper', reasonRef: subRef,
|
|
607
|
+
});
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
// Session sources: Claude Code and Codex logs for every working directory in scope (PA-2).
|
|
613
|
+
const cwds = [...sessionCwds];
|
|
614
|
+
const located = locateSessionsForHomes(cwds, (_host, cwd) => sessionHomes.get(pathKey(cwd)) ?? home);
|
|
615
|
+
const byCwd = new Map<string, { claude: LocatedSession[]; codex: LocatedSession[] }>();
|
|
616
|
+
for (const cwd of cwds) byCwd.set(pathKey(cwd), { claude: [], codex: [] });
|
|
617
|
+
// A session is filed under the directory it was recorded in, whatever spelling its log gives that directory.
|
|
618
|
+
for (const s of located) if (s.host !== 'pi') byCwd.get(pathKey(s.matchedCwd ?? ''))?.[s.host].push(s);
|
|
619
|
+
for (const cwd of cwds) {
|
|
620
|
+
const found = byCwd.get(pathKey(cwd))!;
|
|
621
|
+
const isOriginalOfCopy = items.some((i) => i.copyOf && samePath(i.copyOf, cwd));
|
|
622
|
+
const sessionStoreRoot = sessionHomes.get(pathKey(cwd));
|
|
623
|
+
const sourceHome = sessionStoreRoot ?? home;
|
|
624
|
+
const pairs: [SessionHost, LocatedSession[], string][] = [
|
|
625
|
+
['claude', found.claude, join(sourceHome, '.claude', 'projects')],
|
|
626
|
+
['codex', found.codex, [...new Set(found.codex.map((s) => s.home))].join('; ') || join(sourceHome, '.codex', 'sessions')],
|
|
627
|
+
];
|
|
628
|
+
for (const [host, sessions, where] of pairs) {
|
|
629
|
+
if (sessions.length === 0 && !isOriginalOfCopy) continue;
|
|
630
|
+
// The spelling a log records its directory in is a fact about the session: said here where it is not this one.
|
|
631
|
+
const otherwise = sessions.filter((s) => s.cwd && !samePath(s.cwd, cwd));
|
|
632
|
+
const spelled = otherwise.length ? `, ${otherwise.length} recorded under another spelling of it (${[...new Set(otherwise.map((s) => normalizePath(s.cwd!)))].join(', ')})` : '';
|
|
633
|
+
push({
|
|
634
|
+
id: sessionItemId(host, cwd), path: where, category: 'Session source', relation: 'Session source',
|
|
635
|
+
reason: `${host === 'claude' ? 'Claude Code' : 'Codex'} sessions whose working directory is ${cwd}${isOriginalOfCopy ? ' (the original of a copy; read-only)' : ''}: ${sessions.length} found${sessions.some((s) => s.isSubagent) ? `, ${sessions.filter((s) => s.isSubagent).length} sub-agent` : ''}${spelled}; read from ${sessionStoreRoot ? `frozen session storage ${sessionStoreRoot}` : `host session storage ${home}`}`,
|
|
636
|
+
reasonSourceIds: [], sessionHost: host, readOnly: true, copyOf: null, worktreeOf: null,
|
|
637
|
+
versionControl: 'unknown', missing: sessions.length === 0 ? { reason: 'No sessions found for this directory' } : null,
|
|
638
|
+
addedBy: 'keeper', reasonRef: null, sessions, sessionCwd: cwd,
|
|
639
|
+
...(sessionStoreRoot ? { sessionStoreRoot } : {}),
|
|
640
|
+
});
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
// Missing source kinds: only what the material itself points at.
|
|
645
|
+
for (const location of locations) {
|
|
646
|
+
const evidence = cursorEvidence(location);
|
|
647
|
+
if (evidence && !missing.some((m) => m.kind === 'Cursor sessions')) {
|
|
648
|
+
missing.push({ kind: 'Cursor sessions', reason: `The project shows Cursor use (${evidence}), but ProjectKeeper does not read Cursor session logs yet` });
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
// Ambiguities that change the result (§3.9): a copy whose source could not be named.
|
|
653
|
+
for (const item of items) {
|
|
654
|
+
if (item.relation === 'Copy of another project' && !item.copyOf) {
|
|
655
|
+
const id = stableId('scopeq', 'copy-source', pathKey(item.path));
|
|
656
|
+
const previous = options.existingQuestions?.find((q) => q.id === id);
|
|
657
|
+
questions.push(previous ?? {
|
|
658
|
+
id, question: `${item.path} looks like a copy. Which project is it a copy of?`,
|
|
659
|
+
whyItMatters: 'The sessions and the original repository of a copy live under the original path; without it those sources are missed.',
|
|
660
|
+
clues: ['A copy log exists next to the directory, but it does not name the source.'],
|
|
661
|
+
options: ['Give the original path', 'It is not a copy'], answer: null,
|
|
662
|
+
});
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
const roots = locations;
|
|
667
|
+
const roles = roots.flatMap((r) => detectRoles(r));
|
|
668
|
+
const language = roots.length > 0 ? detectLanguage(roots[0]!) : 'en';
|
|
669
|
+
// B2: toolchain the project's own config points at (Spec §6.7); an allowed read root for the Keeper (§3.1). Read here
|
|
670
|
+
// with the rest of the on-disk discovery; a location too broad to open is flagged (not used) inside discoverToolchain.
|
|
671
|
+
const toolchain = discoverToolchain(locations, { home, projectKeeperHome: options.projectKeeperHome });
|
|
672
|
+
return { items, questions, missingSourceKinds: missing, roles: [...new Set(roles)], language, locations, toolchain };
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
/**
|
|
676
|
+
* What the disk and git say, before anything is decided about it: the part of discovery that reads the project. A
|
|
677
|
+
* change in what the Keeper, the project's rules or the owner decided is applied to it again (`decideScope`) without
|
|
678
|
+
* reading the project again.
|
|
679
|
+
*/
|
|
680
|
+
export interface DiscoveryBase extends DiscoveryResult {
|
|
681
|
+
readonly locations: readonly string[];
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/** Scope discovery: read the project, then apply what was decided about it. */
|
|
685
|
+
export function discoverScope(
|
|
686
|
+
project: { readonly id: string; readonly name: string; readonly locations: readonly string[] },
|
|
687
|
+
options: DiscoveryOptions = {},
|
|
688
|
+
): DiscoveryResult {
|
|
689
|
+
return decideScope(discoverBase(project, options), options);
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
/** What the Keeper, the project's rules and the owner decided about the locations (§1.1, §1.15, §3.9), applied to a base. */
|
|
693
|
+
export function decideScope(base: DiscoveryBase, options: DiscoveryOptions = {}): DiscoveryResult {
|
|
694
|
+
const { locations } = base;
|
|
695
|
+
// Answers the owner gave since the base was read.
|
|
696
|
+
const questions: ScopeQuestion[] = base.questions.map((q) => options.existingQuestions?.find((x) => x.id === q.id && x.answer) ?? q);
|
|
697
|
+
const owning = (path: string) => base.items.filter((i) => i.category !== 'Session source' && isWithin(i.path, path)).sort((a, b) => b.path.length - a.path.length)[0];
|
|
698
|
+
const decided = applyDecisions<DiscoveredItem>(base.items, {
|
|
699
|
+
locations, rules: options.rules ?? [], judgements: options.judgements ?? [], questions: options.existingQuestions ?? [],
|
|
700
|
+
sourceLabel: options.sourceLabel ?? (() => null),
|
|
701
|
+
resolveDir: (words) => {
|
|
702
|
+
if (!words || /[*?]/.test(words)) return null;
|
|
703
|
+
for (const location of locations) {
|
|
704
|
+
const full = normalizePath(isAbsolute(words) ? words : join(location, words.replace(/[\\/]+$/, '')));
|
|
705
|
+
if (!locations.some((l) => isWithin(l, full))) continue;
|
|
706
|
+
try { if (statSync(full).isDirectory()) return full; } catch { /* not a directory here */ }
|
|
707
|
+
}
|
|
708
|
+
return null;
|
|
709
|
+
},
|
|
710
|
+
newItem: (path: string, relation: ScopeRelation): DiscoveredItem => {
|
|
711
|
+
const repo = existsSync(join(path, '.git'));
|
|
712
|
+
return {
|
|
713
|
+
id: itemId(path, repo ? 'repo' : 'dir'), path: normalizePath(path), category: repo ? 'Repository' : 'Directory', relation, reason: '',
|
|
714
|
+
reasonSourceIds: [], sessionHost: null, readOnly: false, copyOf: null, worktreeOf: null, addedBy: 'keeper', reasonRef: null,
|
|
715
|
+
versionControl: repo ? 'git' : owning(path)?.versionControl ?? 'unknown', missing: existsSync(path) ? null : { reason: 'Path does not exist or is not readable' },
|
|
716
|
+
};
|
|
717
|
+
},
|
|
718
|
+
});
|
|
719
|
+
questions.push(...decided.questions.filter((q) => !questions.some((x) => x.id === q.id)));
|
|
720
|
+
|
|
721
|
+
// Owner-edited items win over discovery.
|
|
722
|
+
const ownerItems = options.ownerItems ?? [];
|
|
723
|
+
const merged: DiscoveredItem[] = [];
|
|
724
|
+
const matchedOwners = new Set<ScopeItem>();
|
|
725
|
+
for (const item of decided.items) {
|
|
726
|
+
// Pre-D5 session ids accidentally included the service cwd. Match those persisted owner edits by the intended
|
|
727
|
+
// identity (host + complete cwd), then write the canonical id so later launches do not need another migration.
|
|
728
|
+
const owner = ownerItems.find((o) => o.id === item.id) ?? (item.category === 'Session source' ? ownerItems.find((o) => sameSessionItem(o, item)) : undefined);
|
|
729
|
+
if (owner) matchedOwners.add(owner);
|
|
730
|
+
// Which directory a session item reads is a fact discovery finds, like its sessions, not a decision: an item the
|
|
731
|
+
// owner edited before items recorded it gets it back.
|
|
732
|
+
merged.push(owner ? { ...owner, id: item.id, reasonRef: item.reasonRef, sessions: item.sessions, ...(item.sessionCwd ? { sessionCwd: item.sessionCwd } : {}), sessionStoreRoot: item.sessionStoreRoot } : item);
|
|
733
|
+
}
|
|
734
|
+
for (const owner of ownerItems) {
|
|
735
|
+
if (owner.addedBy === 'owner' && !matchedOwners.has(owner) && !merged.some((m) => m.id === owner.id)) merged.push({ ...owner, reasonRef: null });
|
|
736
|
+
}
|
|
737
|
+
// Answered questions stay on record even when the ambiguity no longer arises, so the answer keeps applying.
|
|
738
|
+
for (const q of options.existingQuestions ?? []) if (q.answer && !questions.some((x) => x.id === q.id)) questions.push(q);
|
|
739
|
+
// B2: the toolchain was read with the base (discoverBase) and carried through unchanged.
|
|
740
|
+
return { items: merged, questions, missingSourceKinds: base.missingSourceKinds, roles: base.roles, language: base.language, toolchain: base.toolchain };
|
|
741
|
+
}
|