@phyzess/pappra 0.1.0-beta.11 → 0.1.0-beta.13
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/README.md +8 -2
- package/assets/integrations/skills/pappra/references/operations.md +5 -0
- package/dist/app/collab-from.d.ts +35 -0
- package/dist/app/collab-from.js +84 -0
- package/dist/app/host-mcp.d.ts +25 -0
- package/dist/app/host-mcp.js +227 -0
- package/dist/app/host-probe.d.ts +16 -0
- package/dist/app/host-probe.js +125 -0
- package/dist/app/init.d.ts +15 -0
- package/dist/app/init.js +82 -3
- package/dist/app/onboard.d.ts +60 -0
- package/dist/app/onboard.js +259 -0
- package/dist/app/task-pin.d.ts +23 -0
- package/dist/app/task-pin.js +113 -0
- package/dist/cli/commands/docs.js +12 -76
- package/dist/cli/commands/host.d.ts +3 -1
- package/dist/cli/commands/host.js +55 -1
- package/dist/cli/commands/init.d.ts +0 -8
- package/dist/cli/commands/init.js +5 -126
- package/dist/cli/commands/mcp.d.ts +8 -0
- package/dist/cli/commands/mcp.js +13 -0
- package/dist/cli/commands/onboard.d.ts +14 -0
- package/dist/cli/commands/onboard.js +225 -0
- package/dist/cli/commands/task.d.ts +10 -0
- package/dist/cli/commands/task.js +76 -0
- package/dist/cli/run.js +206 -7
- package/dist/hosts/integration/hook.js +4 -1
- package/dist/hosts/integration/pappra-extension-runtime.mjs +50 -8
- package/dist/hosts/integration/pappra-hook.mjs +50 -8
- package/dist/knowledge/scan.d.ts +30 -0
- package/dist/knowledge/scan.js +91 -0
- package/dist/mcp/server.d.ts +11 -0
- package/dist/mcp/server.js +518 -0
- package/dist/schemas/workspace.d.ts +6 -0
- package/dist/schemas/workspace.js +4 -0
- package/package.json +2 -1
- package/skills/pappra/references/operations.md +5 -0
package/dist/app/init.js
CHANGED
|
@@ -7,7 +7,7 @@ import { INIT_CHOICES_SCHEMA, initPlanSchema, parseInitChoices, } from '../schem
|
|
|
7
7
|
import { machineConfigSchema } from '../schemas/machine.js';
|
|
8
8
|
import { isExternalRepositoryPath, isPathInside, isPortableRelativePath, normalizePortableRelativePath, resolvePortableRelativePath, samePath, } from '../schemas/paths.js';
|
|
9
9
|
import { WORKSPACE_SCHEMA, validateWorkspaceConfigSemantics, workspaceConfigSchema, } from '../schemas/workspace.js';
|
|
10
|
-
import { getPackageInfo } from '../package-info.js';
|
|
10
|
+
import { getPackageInfo, getPackageRoot } from '../package-info.js';
|
|
11
11
|
import { spawnSync } from 'node:child_process';
|
|
12
12
|
import { executableOnPath } from '../hosts/detect.js';
|
|
13
13
|
import { HOST_SUPPORT } from '../hosts/integration/support.js';
|
|
@@ -17,7 +17,8 @@ import { discoverWorkspace, } from '../workspace/discover.js';
|
|
|
17
17
|
import { resolveWorkspaceRoot } from '../workspace/resolve.js';
|
|
18
18
|
import { buildInitPlan } from '../install/plan.js';
|
|
19
19
|
import { backupPath, ensureStatePathBoundary, readWorkspaceManifest, writeWorkspaceManifest, } from '../install/ownership.js';
|
|
20
|
-
import { beginManagedApply, completeManagedApply, failManagedApply, recordManagedFile, } from '../install/apply.js';
|
|
20
|
+
import { beginManagedApply, completeManagedApply, failManagedApply, recordManagedFile, updateMachineRuntime, } from '../install/apply.js';
|
|
21
|
+
import { installRuntime, } from '../install/runtime.js';
|
|
21
22
|
const workspaceConfigRelativePath = '.pappra/workspace.json';
|
|
22
23
|
const machineConfigRelativePath = '.pappra/local/machine.json';
|
|
23
24
|
function failure(status, code, message, diagnostics = [], nextAction = null) {
|
|
@@ -251,7 +252,7 @@ const derivedExcludes = [
|
|
|
251
252
|
'test-results',
|
|
252
253
|
];
|
|
253
254
|
const derivedValidationScripts = ['lint', 'typecheck', 'test'];
|
|
254
|
-
function detectedHostVersion(command, pathEnv) {
|
|
255
|
+
export function detectedHostVersion(command, pathEnv) {
|
|
255
256
|
const executable = executableOnPath(command, pathEnv);
|
|
256
257
|
if (executable === null)
|
|
257
258
|
return null;
|
|
@@ -1440,3 +1441,81 @@ export async function applyInitPlan(bundle, options = {}) {
|
|
|
1440
1441
|
};
|
|
1441
1442
|
}
|
|
1442
1443
|
}
|
|
1444
|
+
export async function bindAndRecordRuntime(root, installed) {
|
|
1445
|
+
if (!installed.ok || installed.manifest === null) {
|
|
1446
|
+
return {
|
|
1447
|
+
ok: false,
|
|
1448
|
+
completion: false,
|
|
1449
|
+
runtime: installed,
|
|
1450
|
+
diagnostics: installed.diagnostics,
|
|
1451
|
+
};
|
|
1452
|
+
}
|
|
1453
|
+
const updated = await updateMachineRuntime(root, installed.manifest);
|
|
1454
|
+
if (!updated.ok) {
|
|
1455
|
+
return {
|
|
1456
|
+
ok: false,
|
|
1457
|
+
completion: false,
|
|
1458
|
+
runtime: installed,
|
|
1459
|
+
diagnostics: updated.diagnostics,
|
|
1460
|
+
};
|
|
1461
|
+
}
|
|
1462
|
+
const followUp = await planWorkspaceInit({ root });
|
|
1463
|
+
if (!followUp.ok) {
|
|
1464
|
+
return {
|
|
1465
|
+
ok: false,
|
|
1466
|
+
completion: false,
|
|
1467
|
+
runtime: installed,
|
|
1468
|
+
diagnostics: followUp.diagnostics,
|
|
1469
|
+
};
|
|
1470
|
+
}
|
|
1471
|
+
if (followUp.bundle.plan.files.length > 0 ||
|
|
1472
|
+
followUp.bundle.plan.externalActions.length > 0) {
|
|
1473
|
+
return {
|
|
1474
|
+
ok: false,
|
|
1475
|
+
completion: false,
|
|
1476
|
+
runtime: installed,
|
|
1477
|
+
diagnostics: [
|
|
1478
|
+
{
|
|
1479
|
+
code: diagnosticCodes.initRuntimeSourceRequired,
|
|
1480
|
+
severity: 'error',
|
|
1481
|
+
message: 'Runtime installation succeeded but init still has pending file or external actions.',
|
|
1482
|
+
source: 'init runtime',
|
|
1483
|
+
pointer: null,
|
|
1484
|
+
hint: 'Run pappra init again to review the remaining plan; initialization is not complete.',
|
|
1485
|
+
},
|
|
1486
|
+
],
|
|
1487
|
+
};
|
|
1488
|
+
}
|
|
1489
|
+
return {
|
|
1490
|
+
ok: true,
|
|
1491
|
+
completion: true,
|
|
1492
|
+
runtime: installed,
|
|
1493
|
+
diagnostics: [],
|
|
1494
|
+
};
|
|
1495
|
+
}
|
|
1496
|
+
/**
|
|
1497
|
+
* Converges the runtime binding by installing a durable copy of the package that
|
|
1498
|
+
* is currently executing. Reusing an already verified copy is cheap: the install
|
|
1499
|
+
* machinery short-circuits before packing.
|
|
1500
|
+
*/
|
|
1501
|
+
export async function ensureDefaultRuntime(root, targetVersion) {
|
|
1502
|
+
const machine = await inspectMachineConfig(root);
|
|
1503
|
+
const binding = machine.value?.runtime ?? null;
|
|
1504
|
+
if (binding !== null && binding.version === targetVersion) {
|
|
1505
|
+
const executable = await observePath(binding.executable);
|
|
1506
|
+
if (executable.kind === 'file') {
|
|
1507
|
+
return {
|
|
1508
|
+
ok: true,
|
|
1509
|
+
completion: true,
|
|
1510
|
+
runtime: null,
|
|
1511
|
+
diagnostics: [],
|
|
1512
|
+
};
|
|
1513
|
+
}
|
|
1514
|
+
}
|
|
1515
|
+
const installed = await installRuntime({
|
|
1516
|
+
version: targetVersion,
|
|
1517
|
+
source: { kind: 'self', path: getPackageRoot() },
|
|
1518
|
+
packageName: getPackageInfo().name,
|
|
1519
|
+
});
|
|
1520
|
+
return bindAndRecordRuntime(root, installed);
|
|
1521
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { Diagnostic } from '../schemas/diagnostics.js';
|
|
2
|
+
import type { HostName } from '../schemas/host.js';
|
|
3
|
+
import type { InitChoices } from '../schemas/init.js';
|
|
4
|
+
import { type InitContext, type InitPlanBundle } from './init.js';
|
|
5
|
+
export type OnboardHostState = 'verified' | 'unverified' | 'unsupported' | 'missing';
|
|
6
|
+
export interface OnboardHostPlan {
|
|
7
|
+
readonly host: HostName;
|
|
8
|
+
readonly command: string;
|
|
9
|
+
readonly detectedVersion: string | null;
|
|
10
|
+
readonly pinnedVersion: string;
|
|
11
|
+
readonly state: OnboardHostState;
|
|
12
|
+
}
|
|
13
|
+
export interface OnboardHostResult extends OnboardHostPlan {
|
|
14
|
+
readonly action: 'planned' | 'installed' | 'unchanged' | 'skipped' | 'failed';
|
|
15
|
+
readonly appliedFiles: readonly string[];
|
|
16
|
+
readonly nextActions: readonly string[];
|
|
17
|
+
readonly diagnostics: readonly Diagnostic[];
|
|
18
|
+
}
|
|
19
|
+
export interface OnboardPlan {
|
|
20
|
+
readonly context: InitContext;
|
|
21
|
+
readonly choices: InitChoices;
|
|
22
|
+
readonly bundle: InitPlanBundle;
|
|
23
|
+
readonly hosts: readonly OnboardHostPlan[];
|
|
24
|
+
readonly requestedHosts: readonly HostName[];
|
|
25
|
+
}
|
|
26
|
+
export interface OnboardResult {
|
|
27
|
+
readonly schema: 'pappra/onboard/1';
|
|
28
|
+
readonly ok: boolean;
|
|
29
|
+
readonly dryRun: boolean;
|
|
30
|
+
readonly workspaceRoot: string;
|
|
31
|
+
readonly files: readonly {
|
|
32
|
+
readonly path: string;
|
|
33
|
+
readonly operation: string;
|
|
34
|
+
}[];
|
|
35
|
+
readonly runtime: {
|
|
36
|
+
readonly version: string;
|
|
37
|
+
readonly status: string;
|
|
38
|
+
} | null;
|
|
39
|
+
readonly initComplete: boolean;
|
|
40
|
+
readonly hosts: readonly OnboardHostResult[];
|
|
41
|
+
readonly manualSteps: readonly string[];
|
|
42
|
+
readonly nextActions: readonly string[];
|
|
43
|
+
readonly diagnostics: readonly Diagnostic[];
|
|
44
|
+
}
|
|
45
|
+
export interface OnboardFailure {
|
|
46
|
+
readonly ok: false;
|
|
47
|
+
readonly reason: 'invalid' | 'needs_input' | 'unavailable' | 'failed';
|
|
48
|
+
readonly message: string;
|
|
49
|
+
readonly diagnostics: readonly Diagnostic[];
|
|
50
|
+
}
|
|
51
|
+
export declare function parseHostList(value: string | null): HostName[] | null;
|
|
52
|
+
export declare function planOnboard(options: {
|
|
53
|
+
readonly root: string;
|
|
54
|
+
readonly choices?: InitChoices | null;
|
|
55
|
+
readonly hosts?: readonly HostName[] | null;
|
|
56
|
+
readonly pathEnv?: string;
|
|
57
|
+
}): Promise<OnboardPlan | OnboardFailure>;
|
|
58
|
+
export declare function applyOnboard(plan: OnboardPlan, options: {
|
|
59
|
+
readonly applyHosts: boolean;
|
|
60
|
+
}): Promise<OnboardResult>;
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import { mkdir } from 'node:fs/promises';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join, resolve } from 'node:path';
|
|
4
|
+
import { HOST_SUPPORT } from '../hosts/integration/support.js';
|
|
5
|
+
import { exportHostPluginBundle } from '../hosts/integration/bundle-export.js';
|
|
6
|
+
import { HostIntegrationService } from '../hosts/integration/service.js';
|
|
7
|
+
import { getPackageInfo } from '../package-info.js';
|
|
8
|
+
import { resolveWorkspaceRoot } from '../workspace/resolve.js';
|
|
9
|
+
import { applyInitPlan, deriveInitChoices, detectedHostVersion, ensureDefaultRuntime, inspectInitContext, planWorkspaceInit, } from './init.js';
|
|
10
|
+
const HOSTS = ['claude', 'codex', 'pi'];
|
|
11
|
+
const HOST_COMMAND = {
|
|
12
|
+
claude: 'claude',
|
|
13
|
+
codex: 'codex',
|
|
14
|
+
pi: 'pi',
|
|
15
|
+
};
|
|
16
|
+
function sameMinor(left, right) {
|
|
17
|
+
const [leftMajor, leftMinor] = left.split('.');
|
|
18
|
+
const [rightMajor, rightMinor] = right.split('.');
|
|
19
|
+
return leftMajor === rightMajor && leftMinor === rightMinor;
|
|
20
|
+
}
|
|
21
|
+
function hostState(detected, pinned) {
|
|
22
|
+
if (detected === null)
|
|
23
|
+
return 'missing';
|
|
24
|
+
if (detected === pinned)
|
|
25
|
+
return 'verified';
|
|
26
|
+
return sameMinor(detected, pinned) ? 'unverified' : 'unsupported';
|
|
27
|
+
}
|
|
28
|
+
function detectHosts(pathEnv, requested) {
|
|
29
|
+
return HOSTS.filter((host) => requested === null || requested.includes(host)).map((host) => {
|
|
30
|
+
const pinnedVersion = HOST_SUPPORT[host].version;
|
|
31
|
+
const detectedVersion = detectedHostVersion(HOST_COMMAND[host], pathEnv);
|
|
32
|
+
return {
|
|
33
|
+
host,
|
|
34
|
+
command: HOST_COMMAND[host],
|
|
35
|
+
detectedVersion,
|
|
36
|
+
pinnedVersion,
|
|
37
|
+
state: hostState(detectedVersion, pinnedVersion),
|
|
38
|
+
};
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
export function parseHostList(value) {
|
|
42
|
+
if (value === null || value.trim() === '')
|
|
43
|
+
return null;
|
|
44
|
+
const parsed = [];
|
|
45
|
+
for (const entry of value.split(',')) {
|
|
46
|
+
const name = entry.trim();
|
|
47
|
+
if (name === 'claude' || name === 'codex' || name === 'pi') {
|
|
48
|
+
if (!parsed.includes(name))
|
|
49
|
+
parsed.push(name);
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
throw new Error(`Unknown host: ${name}`);
|
|
53
|
+
}
|
|
54
|
+
return parsed;
|
|
55
|
+
}
|
|
56
|
+
export async function planOnboard(options) {
|
|
57
|
+
const requestedRoot = resolve(options.root);
|
|
58
|
+
const resolution = await resolveWorkspaceRoot({ startPath: requestedRoot });
|
|
59
|
+
if (resolution.state === 'unavailable' || resolution.state === 'incomplete') {
|
|
60
|
+
return {
|
|
61
|
+
ok: false,
|
|
62
|
+
reason: 'unavailable',
|
|
63
|
+
message: `Cannot initialize from ${requestedRoot}.`,
|
|
64
|
+
diagnostics: resolution.diagnostics,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
const root = resolution.root ?? requestedRoot;
|
|
68
|
+
const contextResult = await inspectInitContext(root);
|
|
69
|
+
if (!contextResult.ok) {
|
|
70
|
+
return {
|
|
71
|
+
ok: false,
|
|
72
|
+
reason: contextResult.status === 'invalid' ? 'invalid' : 'failed',
|
|
73
|
+
message: contextResult.message,
|
|
74
|
+
diagnostics: contextResult.diagnostics,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
const context = contextResult.context;
|
|
78
|
+
const pathEnv = options.pathEnv ?? process.env['PATH'] ?? '';
|
|
79
|
+
const requestedHosts = options.hosts ?? null;
|
|
80
|
+
const derived = options.choices ??
|
|
81
|
+
(await deriveInitChoices(context.discovery, { pathEnv })).choices;
|
|
82
|
+
const choices = requestedHosts === null
|
|
83
|
+
? derived
|
|
84
|
+
: { ...derived, hosts: [...requestedHosts] };
|
|
85
|
+
const prepared = await planWorkspaceInit({
|
|
86
|
+
root: context.root,
|
|
87
|
+
choices,
|
|
88
|
+
context,
|
|
89
|
+
});
|
|
90
|
+
if (!prepared.ok) {
|
|
91
|
+
return {
|
|
92
|
+
ok: false,
|
|
93
|
+
reason: prepared.status === 'invalid' ? 'invalid' : 'failed',
|
|
94
|
+
message: prepared.message,
|
|
95
|
+
diagnostics: prepared.diagnostics,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
return {
|
|
99
|
+
context,
|
|
100
|
+
choices,
|
|
101
|
+
bundle: prepared.bundle,
|
|
102
|
+
hosts: detectHosts(pathEnv, requestedHosts),
|
|
103
|
+
requestedHosts: choices.hosts,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
function hostTarget(host, root) {
|
|
107
|
+
if (host === 'claude')
|
|
108
|
+
return join(root, '.claude');
|
|
109
|
+
if (host === 'codex') {
|
|
110
|
+
const configured = process.env['CODEX_HOME'];
|
|
111
|
+
return configured === undefined || configured === ''
|
|
112
|
+
? join(homedir(), '.codex')
|
|
113
|
+
: resolve(configured);
|
|
114
|
+
}
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
async function wireHost(plan, root, packageVersion) {
|
|
118
|
+
if (plan.state !== 'verified') {
|
|
119
|
+
const detail = plan.state === 'missing'
|
|
120
|
+
? `Host ${plan.command} is not installed.`
|
|
121
|
+
: `Host ${plan.command} ${plan.detectedVersion ?? '(unknown)'} does not match the pinned ${plan.pinnedVersion}.`;
|
|
122
|
+
return {
|
|
123
|
+
...plan,
|
|
124
|
+
action: 'skipped',
|
|
125
|
+
appliedFiles: [],
|
|
126
|
+
nextActions: [
|
|
127
|
+
`${detail} Install the pinned version, or use pappra host install --mode project-settings explicitly.`,
|
|
128
|
+
],
|
|
129
|
+
diagnostics: [],
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
if (plan.host === 'pi') {
|
|
133
|
+
const outDir = join(root, '.pappra', 'host-bundles', 'pi');
|
|
134
|
+
try {
|
|
135
|
+
const exported = await exportHostPluginBundle({
|
|
136
|
+
host: 'pi',
|
|
137
|
+
hostVersion: plan.pinnedVersion,
|
|
138
|
+
outDir,
|
|
139
|
+
packageVersion,
|
|
140
|
+
});
|
|
141
|
+
return {
|
|
142
|
+
...plan,
|
|
143
|
+
action: 'installed',
|
|
144
|
+
appliedFiles: exported.files.map((file) => `host-bundles/pi/${file}`),
|
|
145
|
+
nextActions: [
|
|
146
|
+
`Run: pi install ${outDir} -l -a`,
|
|
147
|
+
'Approve the project package trust prompt once in Pi.',
|
|
148
|
+
],
|
|
149
|
+
diagnostics: [],
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
catch (error) {
|
|
153
|
+
return {
|
|
154
|
+
...plan,
|
|
155
|
+
action: 'failed',
|
|
156
|
+
appliedFiles: [],
|
|
157
|
+
nextActions: [error instanceof Error ? error.message : String(error)],
|
|
158
|
+
diagnostics: [],
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
const target = hostTarget(plan.host, root);
|
|
163
|
+
if (target === null) {
|
|
164
|
+
return {
|
|
165
|
+
...plan,
|
|
166
|
+
action: 'skipped',
|
|
167
|
+
appliedFiles: [],
|
|
168
|
+
nextActions: [`No install root is defined for ${plan.host}.`],
|
|
169
|
+
diagnostics: [],
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
await mkdir(target, { recursive: true });
|
|
173
|
+
const service = new HostIntegrationService({
|
|
174
|
+
root,
|
|
175
|
+
homeDir: homedir(),
|
|
176
|
+
pathEnv: process.env['PATH'] ?? '',
|
|
177
|
+
hostVersion: plan.pinnedVersion,
|
|
178
|
+
observedAt: new Date().toISOString(),
|
|
179
|
+
});
|
|
180
|
+
const installed = await service.install(plan.host, {
|
|
181
|
+
hostRoot: target,
|
|
182
|
+
hostVersion: plan.pinnedVersion,
|
|
183
|
+
apply: true,
|
|
184
|
+
});
|
|
185
|
+
const trust = plan.host === 'codex'
|
|
186
|
+
? 'Approve the Pappra hook once in Codex; project `.codex/hooks.json` is not loaded by 0.163.0.'
|
|
187
|
+
: 'Reload the session and trust the Pappra hook if Claude Code prompts.';
|
|
188
|
+
return {
|
|
189
|
+
...plan,
|
|
190
|
+
action: installed.status === 'applied'
|
|
191
|
+
? 'installed'
|
|
192
|
+
: installed.status === 'unchanged'
|
|
193
|
+
? 'unchanged'
|
|
194
|
+
: 'failed',
|
|
195
|
+
appliedFiles: installed.appliedFiles,
|
|
196
|
+
nextActions: [trust],
|
|
197
|
+
diagnostics: installed.diagnostics,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
export async function applyOnboard(plan, options) {
|
|
201
|
+
const applied = await applyInitPlan(plan.bundle);
|
|
202
|
+
const diagnostics = [
|
|
203
|
+
...plan.bundle.diagnostics,
|
|
204
|
+
...applied.diagnostics,
|
|
205
|
+
].filter((entry) => entry.severity !== 'info');
|
|
206
|
+
const runtime = applied.ok
|
|
207
|
+
? await ensureDefaultRuntime(plan.bundle.scope.workspaceRoot, plan.bundle.scope.runtimeVersion)
|
|
208
|
+
: null;
|
|
209
|
+
if (runtime !== null) {
|
|
210
|
+
diagnostics.push(...runtime.diagnostics.filter((entry) => entry.severity !== 'info'));
|
|
211
|
+
}
|
|
212
|
+
const initComplete = applied.ok &&
|
|
213
|
+
applied.residualPaths.length === 0 &&
|
|
214
|
+
runtime?.completion === true;
|
|
215
|
+
const packageVersion = getPackageInfo().version;
|
|
216
|
+
const hosts = [];
|
|
217
|
+
const manualSteps = [];
|
|
218
|
+
for (const host of plan.hosts) {
|
|
219
|
+
const result = options.applyHosts
|
|
220
|
+
? await wireHost(host, plan.bundle.scope.workspaceRoot, packageVersion)
|
|
221
|
+
: {
|
|
222
|
+
...host,
|
|
223
|
+
action: 'planned',
|
|
224
|
+
appliedFiles: [],
|
|
225
|
+
nextActions: [
|
|
226
|
+
'Rerun with --apply-host to write this host integration.',
|
|
227
|
+
],
|
|
228
|
+
diagnostics: [],
|
|
229
|
+
};
|
|
230
|
+
hosts.push(result);
|
|
231
|
+
manualSteps.push(...result.nextActions);
|
|
232
|
+
}
|
|
233
|
+
const nextActions = [];
|
|
234
|
+
if (plan.choices.knowledge.sources.length > 0) {
|
|
235
|
+
nextActions.push('pappra docs scan --apply');
|
|
236
|
+
}
|
|
237
|
+
if (plan.choices.code.graph !== 'none' &&
|
|
238
|
+
plan.choices.repositories.length > 0) {
|
|
239
|
+
nextActions.push(`pappra code prepare --repo ${plan.choices.repositories[0]?.path ?? '<repo>'} --apply`);
|
|
240
|
+
}
|
|
241
|
+
return {
|
|
242
|
+
schema: 'pappra/onboard/1',
|
|
243
|
+
ok: initComplete && hosts.every((host) => host.action !== 'failed'),
|
|
244
|
+
dryRun: false,
|
|
245
|
+
workspaceRoot: plan.bundle.scope.workspaceRoot,
|
|
246
|
+
files: applied.appliedFiles.map((path) => ({ path, operation: 'write' })),
|
|
247
|
+
runtime: runtime?.runtime === null || runtime === null
|
|
248
|
+
? null
|
|
249
|
+
: {
|
|
250
|
+
version: runtime.runtime.version,
|
|
251
|
+
status: runtime.runtime.status,
|
|
252
|
+
},
|
|
253
|
+
initComplete,
|
|
254
|
+
hosts,
|
|
255
|
+
manualSteps: [...new Set(manualSteps)],
|
|
256
|
+
nextActions,
|
|
257
|
+
diagnostics,
|
|
258
|
+
};
|
|
259
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export declare const TASK_PIN_SCHEMA = "pappra/task-pin/1";
|
|
2
|
+
export interface TaskPin {
|
|
3
|
+
readonly schema: typeof TASK_PIN_SCHEMA;
|
|
4
|
+
readonly taskId: string;
|
|
5
|
+
readonly pinnedAt: string;
|
|
6
|
+
}
|
|
7
|
+
export interface TaskPinFailure {
|
|
8
|
+
readonly ok: false;
|
|
9
|
+
readonly message: string;
|
|
10
|
+
}
|
|
11
|
+
export declare function readTaskPin(root: string): Promise<TaskPin | null>;
|
|
12
|
+
export declare function pinTask(root: string, taskId: string): Promise<TaskPin | TaskPinFailure>;
|
|
13
|
+
export declare function unpinTask(root: string): Promise<{
|
|
14
|
+
ok: true;
|
|
15
|
+
}>;
|
|
16
|
+
/**
|
|
17
|
+
* Toggles the workspace-level auto-resume switch. The hook only injects a resume
|
|
18
|
+
* packet when this switch is on and a task pin exists; neither is inferred.
|
|
19
|
+
*/
|
|
20
|
+
export declare function setAutoResume(root: string, enabled: boolean): Promise<{
|
|
21
|
+
ok: true;
|
|
22
|
+
} | TaskPinFailure>;
|
|
23
|
+
export declare function readAutoResume(root: string): Promise<boolean>;
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { readTextFileBounded } from '../infra/fs.js';
|
|
5
|
+
import { workspaceConfigSchema } from '../schemas/workspace.js';
|
|
6
|
+
export const TASK_PIN_SCHEMA = 'pappra/task-pin/1';
|
|
7
|
+
const TASK_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
8
|
+
function pinPath(root) {
|
|
9
|
+
return join(root, '.pappra', 'local', 'current-task');
|
|
10
|
+
}
|
|
11
|
+
export async function readTaskPin(root) {
|
|
12
|
+
const read = await readTextFileBounded(pinPath(root), 64 * 1024);
|
|
13
|
+
if (!read.ok)
|
|
14
|
+
return null;
|
|
15
|
+
try {
|
|
16
|
+
const value = JSON.parse(read.value.text);
|
|
17
|
+
if (typeof value !== 'object' ||
|
|
18
|
+
value === null ||
|
|
19
|
+
!('schema' in value) ||
|
|
20
|
+
value.schema !== TASK_PIN_SCHEMA ||
|
|
21
|
+
!('taskId' in value) ||
|
|
22
|
+
typeof value.taskId !== 'string' ||
|
|
23
|
+
!TASK_ID.test(value.taskId)) {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
return value;
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
export async function pinTask(root, taskId) {
|
|
33
|
+
if (!TASK_ID.test(taskId)) {
|
|
34
|
+
return {
|
|
35
|
+
ok: false,
|
|
36
|
+
message: 'task id must be a short identifier (letters, digits, dot, dash, underscore).',
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
const pin = {
|
|
40
|
+
schema: TASK_PIN_SCHEMA,
|
|
41
|
+
taskId,
|
|
42
|
+
pinnedAt: new Date().toISOString(),
|
|
43
|
+
};
|
|
44
|
+
const target = pinPath(root);
|
|
45
|
+
await mkdir(dirname(target), { recursive: true, mode: 0o700 });
|
|
46
|
+
const temporary = `${target}.tmp-${randomUUID()}`;
|
|
47
|
+
await writeFile(temporary, `${JSON.stringify(pin, null, 2)}\n`, {
|
|
48
|
+
mode: 0o600,
|
|
49
|
+
});
|
|
50
|
+
await rename(temporary, target);
|
|
51
|
+
return pin;
|
|
52
|
+
}
|
|
53
|
+
export async function unpinTask(root) {
|
|
54
|
+
const target = pinPath(root);
|
|
55
|
+
const { rm } = await import('node:fs/promises');
|
|
56
|
+
await rm(target, { force: true });
|
|
57
|
+
return { ok: true };
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Toggles the workspace-level auto-resume switch. The hook only injects a resume
|
|
61
|
+
* packet when this switch is on and a task pin exists; neither is inferred.
|
|
62
|
+
*/
|
|
63
|
+
export async function setAutoResume(root, enabled) {
|
|
64
|
+
const configPath = join(root, '.pappra', 'workspace.json');
|
|
65
|
+
const read = await readTextFileBounded(configPath, 1024 * 1024);
|
|
66
|
+
if (!read.ok) {
|
|
67
|
+
return { ok: false, message: read.message };
|
|
68
|
+
}
|
|
69
|
+
let raw;
|
|
70
|
+
try {
|
|
71
|
+
raw = JSON.parse(read.value.text);
|
|
72
|
+
}
|
|
73
|
+
catch (error) {
|
|
74
|
+
return {
|
|
75
|
+
ok: false,
|
|
76
|
+
message: `workspace.json is not valid JSON: ${error instanceof Error ? error.message : String(error)}`,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
const parsed = workspaceConfigSchema.safeParse({
|
|
80
|
+
...(typeof raw === 'object' && raw !== null ? raw : {}),
|
|
81
|
+
continuity: { autoResume: enabled },
|
|
82
|
+
});
|
|
83
|
+
if (!parsed.success) {
|
|
84
|
+
return {
|
|
85
|
+
ok: false,
|
|
86
|
+
message: `workspace.json rejected the continuity switch: ${parsed.error.issues[0]?.message ?? 'invalid'}`,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
const backup = join(root, '.pappra', 'local', 'backups', `task-pin-${randomUUID()}`, 'workspace.json');
|
|
90
|
+
await mkdir(dirname(backup), { recursive: true, mode: 0o700 });
|
|
91
|
+
await writeFile(backup, read.value.text, { mode: 0o600 });
|
|
92
|
+
const temporary = `${configPath}.tmp-${randomUUID()}`;
|
|
93
|
+
await writeFile(temporary, `${JSON.stringify(parsed.data, null, 2)}\n`);
|
|
94
|
+
await rename(temporary, configPath);
|
|
95
|
+
return { ok: true };
|
|
96
|
+
}
|
|
97
|
+
export async function readAutoResume(root) {
|
|
98
|
+
const read = await readFile(join(root, '.pappra', 'workspace.json'), 'utf8');
|
|
99
|
+
try {
|
|
100
|
+
const raw = JSON.parse(read);
|
|
101
|
+
if (typeof raw !== 'object' || raw === null || !('continuity' in raw)) {
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
const continuity = raw.continuity;
|
|
105
|
+
return (typeof continuity === 'object' &&
|
|
106
|
+
continuity !== null &&
|
|
107
|
+
'autoResume' in continuity &&
|
|
108
|
+
continuity.autoResume === true);
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { discoverWorkspace } from '../../workspace/discover.js';
|
|
2
1
|
import { resolveWorkspaceRoot } from '../../workspace/resolve.js';
|
|
2
|
+
import { scanDiscoveredDocuments } from '../../knowledge/scan.js';
|
|
3
3
|
import { formatDiagnostic } from '../../schemas/diagnostics.js';
|
|
4
4
|
import { docsArchiveReportSchema, docsCheckReportSchema, docsListReportSchema, } from '../../schemas/document.js';
|
|
5
5
|
import { KnowledgeService, } from '../../knowledge/index.js';
|
|
@@ -10,96 +10,32 @@ export const docsUsage = 'pappra docs <list|check|archive|scan> [options]\n' +
|
|
|
10
10
|
' scan [--apply] [--json]\n\n' +
|
|
11
11
|
'scan registers documents discovered under the configured knowledge sources; without\n' +
|
|
12
12
|
'--apply it only reports the candidates.';
|
|
13
|
-
function classifyScanKind(path) {
|
|
14
|
-
const lower = path.toLowerCase();
|
|
15
|
-
if (lower.endsWith('agents.md') || lower.endsWith('claude.md'))
|
|
16
|
-
return 'rule';
|
|
17
|
-
if (lower.includes('/research/'))
|
|
18
|
-
return 'research';
|
|
19
|
-
if (lower.includes('/adr/') || lower.includes('decision'))
|
|
20
|
-
return 'decision';
|
|
21
|
-
if (lower.includes('review') || lower.includes('retro'))
|
|
22
|
-
return 'review';
|
|
23
|
-
if (lower.includes('plan'))
|
|
24
|
-
return 'plan';
|
|
25
|
-
return 'architecture';
|
|
26
|
-
}
|
|
27
13
|
async function runDocsScan(options, output, root) {
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
}
|
|
33
|
-
const service = new KnowledgeService(root);
|
|
34
|
-
const listed = await service.list({ includePrivate: true });
|
|
35
|
-
const registered = new Set(listed.records.flatMap((record) => record.location.kind === 'workspace' ? [record.location.path] : []));
|
|
36
|
-
const sources = config.knowledge.sources;
|
|
37
|
-
const visibility = config.knowledge.taskVisibility === 'shared' ? 'shareable' : 'private';
|
|
38
|
-
const candidates = (discovery.documents ?? []).flatMap((document) => {
|
|
39
|
-
const path = document.workspaceRelativePath;
|
|
40
|
-
if (path === null)
|
|
41
|
-
return [];
|
|
42
|
-
if (!sources.some((source) => path === source || path.startsWith(`${source}/`))) {
|
|
43
|
-
return [];
|
|
44
|
-
}
|
|
45
|
-
if (registered.has(path))
|
|
46
|
-
return [];
|
|
47
|
-
return [{ path, kind: classifyScanKind(path), visibility }];
|
|
48
|
-
});
|
|
14
|
+
const result = await scanDiscoveredDocuments(root, { apply: options.apply });
|
|
15
|
+
if ('message' in result) {
|
|
16
|
+
return invalidArguments(output, options.json, result.message);
|
|
17
|
+
}
|
|
49
18
|
if (!options.apply) {
|
|
50
19
|
if (options.json) {
|
|
51
|
-
output.stdout(`${JSON.stringify(
|
|
52
|
-
ok: true,
|
|
53
|
-
schema: 'pappra/docs-scan/1',
|
|
54
|
-
mode: 'plan',
|
|
55
|
-
workspaceRoot: root,
|
|
56
|
-
candidates,
|
|
57
|
-
registered: registered.size,
|
|
58
|
-
nextActions: [
|
|
59
|
-
'Rerun with --apply to register these documents in the knowledge catalog.',
|
|
60
|
-
],
|
|
61
|
-
})}\n`);
|
|
20
|
+
output.stdout(`${JSON.stringify(result)}\n`);
|
|
62
21
|
}
|
|
63
22
|
else {
|
|
64
|
-
output.stdout(`Scan candidates: ${candidates.length}\n`);
|
|
65
|
-
for (const candidate of candidates) {
|
|
23
|
+
output.stdout(`Scan candidates: ${result.candidates.length}\n`);
|
|
24
|
+
for (const candidate of result.candidates) {
|
|
66
25
|
output.stdout(` ${candidate.kind} ${candidate.path}\n`);
|
|
67
26
|
}
|
|
68
27
|
output.stdout('Plan only; rerun with --apply to register them.\n');
|
|
69
28
|
}
|
|
70
29
|
return 0;
|
|
71
30
|
}
|
|
72
|
-
const registeredNow = [];
|
|
73
|
-
const diagnostics = [];
|
|
74
|
-
for (const candidate of candidates) {
|
|
75
|
-
const result = await service.register({
|
|
76
|
-
location: { kind: 'workspace', path: candidate.path },
|
|
77
|
-
kind: candidate.kind,
|
|
78
|
-
maturity: 'draft',
|
|
79
|
-
owner: 'human',
|
|
80
|
-
visibility: candidate.visibility,
|
|
81
|
-
});
|
|
82
|
-
diagnostics.push(...result.diagnostics);
|
|
83
|
-
if (result.ok)
|
|
84
|
-
registeredNow.push(result.record);
|
|
85
|
-
}
|
|
86
|
-
const ok = diagnostics.every((entry) => entry.severity !== 'error');
|
|
87
31
|
if (options.json) {
|
|
88
|
-
output.stdout(`${JSON.stringify(
|
|
89
|
-
ok,
|
|
90
|
-
schema: 'pappra/docs-scan/1',
|
|
91
|
-
mode: 'apply',
|
|
92
|
-
workspaceRoot: root,
|
|
93
|
-
registered: registeredNow,
|
|
94
|
-
skipped: candidates.length - registeredNow.length,
|
|
95
|
-
diagnostics,
|
|
96
|
-
})}\n`);
|
|
32
|
+
output.stdout(`${JSON.stringify(result)}\n`);
|
|
97
33
|
}
|
|
98
34
|
else {
|
|
99
|
-
output.stdout(`Registered ${
|
|
100
|
-
printDiagnostics(output, diagnostics);
|
|
35
|
+
output.stdout(`Registered ${result.registered.length} document(s).\n`);
|
|
36
|
+
printDiagnostics(output, result.diagnostics);
|
|
101
37
|
}
|
|
102
|
-
return ok ? 0 : 1;
|
|
38
|
+
return result.ok ? 0 : 1;
|
|
103
39
|
}
|
|
104
40
|
function printDiagnostics(output, diagnostics) {
|
|
105
41
|
for (const diagnostic of diagnostics) {
|