@guanlin4924/devflow-pi 0.1.62 → 0.1.63
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 +1 -36
- package/package.json +1 -1
- package/plugins/devflow-core/extensions/devflow-core.ts +149 -3
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/test_command_policy.py +42 -5
- package/plugins/devflow-core/skills/workflow-project-context/SKILL.md +62 -787
- package/plugins/devflow-core/skills/workflow-project-context/scripts/validate_initialization_database.py +156 -0
- package/plugins/devflow-intel/extensions/devflow-intel.ts +12 -2
- package/plugins/devflow-intel/skills/workflow-code-index/scripts/adapters/generic_text.py +14 -10
- package/plugins/devflow-intel/skills/workflow-code-index/scripts/adapters/python_ast.py +124 -0
- package/plugins/devflow-intel/skills/workflow-code-index/scripts/adapters/registry.py +11 -2
- package/plugins/devflow-intel/skills/workflow-code-index/scripts/adapters/structured_scope.py +121 -0
- package/plugins/devflow-intel/skills/workflow-code-index/scripts/code_index.py +107 -9
- package/src/cli.ts +12 -2
- package/src/constants.ts +1 -1
- package/src/initialization-store.ts +656 -0
- package/src/paths.ts +9 -0
- package/src/project-context.ts +17 -2
- package/src/project-documents.ts +111 -0
- package/src/project-initializer.ts +251 -0
- package/src/project-migration.ts +66 -0
|
@@ -3,6 +3,7 @@ import argparse
|
|
|
3
3
|
import contextlib
|
|
4
4
|
import json
|
|
5
5
|
import os
|
|
6
|
+
import re
|
|
6
7
|
import shutil
|
|
7
8
|
import sqlite3
|
|
8
9
|
import subprocess
|
|
@@ -59,12 +60,14 @@ def _index_lock(output: Path, exclusive: bool):
|
|
|
59
60
|
|
|
60
61
|
def _publish_snapshot(staging: Path, output: Path, counts: Dict[str, int], workspace_state: Dict[str, object]) -> None:
|
|
61
62
|
generation = str(uuid.uuid4())
|
|
63
|
+
immutable_relative = f"snapshots/{generation}"
|
|
62
64
|
snapshot = {
|
|
63
65
|
"schemaVersion": 1,
|
|
64
66
|
"generation": generation,
|
|
65
67
|
"generatedAt": datetime.now(timezone.utc).isoformat(),
|
|
66
68
|
"counts": counts,
|
|
67
69
|
"workspace": workspace_state,
|
|
70
|
+
"immutablePath": immutable_relative,
|
|
68
71
|
"artifacts": [*INDEX_FILES.values(), "index.sqlite", *MARKDOWN_FILES],
|
|
69
72
|
}
|
|
70
73
|
snapshot_path = staging / SNAPSHOT_FILE
|
|
@@ -72,9 +75,21 @@ def _publish_snapshot(staging: Path, output: Path, counts: Dict[str, int], works
|
|
|
72
75
|
with snapshot_path.open("rb") as handle:
|
|
73
76
|
os.fsync(handle.fileno())
|
|
74
77
|
|
|
78
|
+
snapshots = output / "snapshots"
|
|
79
|
+
snapshots.mkdir(parents=True, exist_ok=True)
|
|
80
|
+
immutable = snapshots / generation
|
|
81
|
+
os.replace(staging, immutable)
|
|
82
|
+
|
|
83
|
+
# Existing consumers keep reading the root paths. They are projections of
|
|
84
|
+
# one immutable generation; SNAPSHOT.json is switched last as the commit
|
|
85
|
+
# marker so readers never accept an uncommitted generation.
|
|
75
86
|
for name in snapshot["artifacts"]:
|
|
76
|
-
|
|
77
|
-
|
|
87
|
+
source = immutable / name
|
|
88
|
+
temporary = output / f".{name}.{generation}.tmp"
|
|
89
|
+
shutil.copy2(source, temporary)
|
|
90
|
+
os.replace(temporary, output / name)
|
|
91
|
+
shutil.copy2(immutable / SNAPSHOT_FILE, output / f".{SNAPSHOT_FILE}.{generation}.tmp")
|
|
92
|
+
os.replace(output / f".{SNAPSHOT_FILE}.{generation}.tmp", output / SNAPSHOT_FILE)
|
|
78
93
|
directory_fd = os.open(output, os.O_RDONLY)
|
|
79
94
|
try:
|
|
80
95
|
os.fsync(directory_fd)
|
|
@@ -90,6 +105,12 @@ def _workspace_state(root: Path, files: List[dict], changed: Set[str]) -> Dict[s
|
|
|
90
105
|
"contentManifestHash": source_manifest_hash,
|
|
91
106
|
"indexedPathsHash": _hash_lines(indexed_paths),
|
|
92
107
|
"dirtyPathsAtBuild": sorted(changed),
|
|
108
|
+
"extractors": sorted(set(record.get("parser", "unknown") for record in files)),
|
|
109
|
+
"capabilities": {
|
|
110
|
+
"python-ast@1": {"scope": "exact syntax tree", "targets": "lexical names; dynamic dispatch unresolved"},
|
|
111
|
+
"structured-scope@1": {"scope": "inferred lexical braces/end blocks", "targets": "name-only heuristic"},
|
|
112
|
+
"generic-text@2": {"scope": "file-level", "targets": "heuristic only"},
|
|
113
|
+
},
|
|
93
114
|
}
|
|
94
115
|
|
|
95
116
|
|
|
@@ -152,6 +173,15 @@ def main(argv=None) -> int:
|
|
|
152
173
|
query.add_argument("--index", default=".planning/code-index", help="Index directory.")
|
|
153
174
|
query.add_argument("--term", required=True, help="Search term.")
|
|
154
175
|
query.add_argument("--limit", type=int, default=20)
|
|
176
|
+
query.add_argument("--snapshot", help="Immutable generation id. Historical queries do not require current workspace freshness.")
|
|
177
|
+
|
|
178
|
+
relations = sub.add_parser("relations", help="Traverse resolved relations in a fixed snapshot.")
|
|
179
|
+
relations.add_argument("--index", default=".planning/code-index")
|
|
180
|
+
relations.add_argument("--object", required=True, help="Source symbol id.")
|
|
181
|
+
relations.add_argument("--depth", type=int, default=3)
|
|
182
|
+
relations.add_argument("--limit", type=int, default=100)
|
|
183
|
+
relations.add_argument("--snapshot")
|
|
184
|
+
relations.add_argument("--include-heuristic", action="store_true")
|
|
155
185
|
|
|
156
186
|
args = parser.parse_args(argv)
|
|
157
187
|
if args.command == "scan":
|
|
@@ -162,6 +192,8 @@ def main(argv=None) -> int:
|
|
|
162
192
|
return command_audit(args)
|
|
163
193
|
if args.command == "query":
|
|
164
194
|
return command_query(args)
|
|
195
|
+
if args.command == "relations":
|
|
196
|
+
return command_relations(args)
|
|
165
197
|
return 2
|
|
166
198
|
|
|
167
199
|
|
|
@@ -188,11 +220,11 @@ def command_scan(args) -> int:
|
|
|
188
220
|
all_files = [path for path in all_files if _workspace_rel(root, path) in changed]
|
|
189
221
|
manifests = [path for path in all_files if path.name in {"package.json", "go.mod", "Cargo.toml", "pyproject.toml", "requirements.txt", "composer.json", "pubspec.yaml"}]
|
|
190
222
|
adapters = registry.adapters_for(codebase, all_files, manifests)
|
|
191
|
-
adapter = adapters[0]
|
|
192
223
|
for path in all_files:
|
|
193
224
|
rel_path = _workspace_rel(root, path)
|
|
194
225
|
if rel_path in indexed_paths:
|
|
195
226
|
continue
|
|
227
|
+
adapter = adapters[-1]
|
|
196
228
|
try:
|
|
197
229
|
text = path.read_text(encoding="utf-8", errors="ignore")
|
|
198
230
|
content_hash = file_sha256(path)
|
|
@@ -204,8 +236,10 @@ def command_scan(args) -> int:
|
|
|
204
236
|
"size_bytes": path.stat().st_size,
|
|
205
237
|
"content_hash": content_hash,
|
|
206
238
|
"indexed_at": datetime.now(timezone.utc).isoformat(),
|
|
207
|
-
"parser":
|
|
239
|
+
"parser": "pending",
|
|
208
240
|
}
|
|
241
|
+
adapter = registry.adapter_for_file(codebase, file_record)
|
|
242
|
+
file_record["parser"] = adapter.parser_id
|
|
209
243
|
result = adapter.parse_file(codebase, file_record, text)
|
|
210
244
|
files.append(file_record)
|
|
211
245
|
symbols.extend(result.symbols)
|
|
@@ -261,13 +295,14 @@ def command_validate(args) -> int:
|
|
|
261
295
|
def command_query(args) -> int:
|
|
262
296
|
output = Path(args.index).resolve()
|
|
263
297
|
with _index_lock(output, exclusive=False):
|
|
264
|
-
|
|
298
|
+
selected = _selected_generation(output, getattr(args, "snapshot", None))
|
|
299
|
+
index = selected / "index.sqlite"
|
|
265
300
|
if not index.exists():
|
|
266
301
|
print(f"Missing index: {index}", file=sys.stderr)
|
|
267
302
|
return 1
|
|
268
|
-
snapshot = _load_snapshot(
|
|
269
|
-
files = read_jsonl(
|
|
270
|
-
if not _query_is_fresh(output.parent.parent, snapshot, files):
|
|
303
|
+
snapshot = _load_snapshot(selected)
|
|
304
|
+
files = read_jsonl(selected / INDEX_FILES["files"])
|
|
305
|
+
if not getattr(args, "snapshot", None) and not _query_is_fresh(output.parent.parent, snapshot, files):
|
|
271
306
|
print("Stale code index: refresh required before query.", file=sys.stderr)
|
|
272
307
|
return 1
|
|
273
308
|
conn = sqlite3.connect(str(index))
|
|
@@ -302,6 +337,49 @@ def command_query(args) -> int:
|
|
|
302
337
|
return 0
|
|
303
338
|
|
|
304
339
|
|
|
340
|
+
def command_relations(args) -> int:
|
|
341
|
+
output = Path(args.index).resolve()
|
|
342
|
+
depth = max(0, min(args.depth, 20)); limit = max(1, min(args.limit, 1000))
|
|
343
|
+
with _index_lock(output, exclusive=False):
|
|
344
|
+
selected = _selected_generation(output, getattr(args, "snapshot", None))
|
|
345
|
+
snapshot = _load_snapshot(selected); files = read_jsonl(selected / INDEX_FILES["files"])
|
|
346
|
+
if not getattr(args, "snapshot", None) and not _query_is_fresh(output.parent.parent, snapshot, files):
|
|
347
|
+
print("Stale code index: refresh required before query.", file=sys.stderr); return 1
|
|
348
|
+
conn = sqlite3.connect(str(selected / "index.sqlite")); conn.row_factory = sqlite3.Row
|
|
349
|
+
try:
|
|
350
|
+
if depth == 0:
|
|
351
|
+
print(json.dumps({"_meta": {"snapshot": snapshot.get("generation"), "depthLimit": 0, "resultLimit": limit, "truncated": False}}, ensure_ascii=False, sort_keys=True))
|
|
352
|
+
return 0
|
|
353
|
+
confidence = "" if args.include_heuristic else "AND r.confidence != 'heuristic'"
|
|
354
|
+
rows = conn.execute(f"""
|
|
355
|
+
WITH RECURSIVE graph(from_id,relation_type,to_id,to_external,evidence_path,evidence_line,confidence,depth,visited) AS (
|
|
356
|
+
SELECT r.from_id,r.relation_type,r.to_id,r.to_external,r.evidence_path,r.evidence_line,r.confidence,1,',' || r.from_id || ',' || COALESCE(r.to_id,'') || ','
|
|
357
|
+
FROM relations r WHERE r.from_id=? {confidence}
|
|
358
|
+
UNION ALL
|
|
359
|
+
SELECT r.from_id,r.relation_type,r.to_id,r.to_external,r.evidence_path,r.evidence_line,r.confidence,g.depth+1,g.visited || COALESCE(r.to_id,'') || ','
|
|
360
|
+
FROM graph g JOIN relations r ON r.from_id=g.to_id
|
|
361
|
+
WHERE g.depth < ? AND r.to_id IS NOT NULL {confidence} AND instr(g.visited, ',' || r.to_id || ',')=0
|
|
362
|
+
) SELECT * FROM graph ORDER BY depth,from_id,relation_type,to_id LIMIT ?
|
|
363
|
+
""", (args.object, depth, limit + 1)).fetchall()
|
|
364
|
+
truncated = len(rows) > limit
|
|
365
|
+
for row in rows[:limit]: print(json.dumps(dict(row), ensure_ascii=False, sort_keys=True))
|
|
366
|
+
print(json.dumps({"_meta": {"snapshot": snapshot.get("generation"), "depthLimit": depth, "resultLimit": limit, "truncated": truncated}}, ensure_ascii=False, sort_keys=True))
|
|
367
|
+
finally: conn.close()
|
|
368
|
+
return 0
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _selected_generation(output: Path, generation: str | None) -> Path:
|
|
372
|
+
if not generation:
|
|
373
|
+
return output
|
|
374
|
+
if not re.fullmatch(r"[0-9a-f-]{36}", generation, re.I):
|
|
375
|
+
raise ValueError("invalid snapshot generation")
|
|
376
|
+
selected = output / "snapshots" / generation
|
|
377
|
+
snapshot = _load_snapshot(selected)
|
|
378
|
+
if snapshot.get("generation") != generation:
|
|
379
|
+
raise ValueError(f"unknown snapshot generation: {generation}")
|
|
380
|
+
return selected
|
|
381
|
+
|
|
382
|
+
|
|
305
383
|
def _load_snapshot(output: Path) -> Dict[str, object]:
|
|
306
384
|
path = output / SNAPSHOT_FILE
|
|
307
385
|
if not path.exists():
|
|
@@ -318,6 +396,18 @@ def _query_is_fresh(root: Path, snapshot: Dict[str, object], files: List[dict])
|
|
|
318
396
|
indexed_manifest = _hash_lines(f"{record['path']}\0{record['content_hash']}" for record in files)
|
|
319
397
|
if workspace.get("contentManifestHash") != indexed_manifest:
|
|
320
398
|
return False
|
|
399
|
+
for record in files:
|
|
400
|
+
source = (root / record["path"]).resolve()
|
|
401
|
+
try:
|
|
402
|
+
if not source.is_file() or file_sha256(source) != record["content_hash"]:
|
|
403
|
+
return False
|
|
404
|
+
except OSError:
|
|
405
|
+
return False
|
|
406
|
+
indexed_paths = sorted(record["path"] for record in files)
|
|
407
|
+
if not any(path.startswith("../") for path in indexed_paths):
|
|
408
|
+
current_paths = sorted(_workspace_rel(root, item) for item in scan_files(root))
|
|
409
|
+
if _hash_lines(current_paths) != workspace.get("indexedPathsHash"):
|
|
410
|
+
return False
|
|
321
411
|
return True
|
|
322
412
|
|
|
323
413
|
|
|
@@ -432,13 +522,21 @@ def _resolve_relations(symbols: List[dict], relations: List[dict]) -> List[dict]
|
|
|
432
522
|
continue
|
|
433
523
|
name = relation["to_external"][5:]
|
|
434
524
|
candidates = by_name.get((source["codebase"], name), [])
|
|
435
|
-
|
|
525
|
+
resolution = relation.get("metadata", {}).get("resolution")
|
|
526
|
+
may_resolve = resolution == "lexical_name" or relation.get("metadata", {}).get("allow_unique_name_resolution") is True
|
|
527
|
+
if len(candidates) == 1 and may_resolve:
|
|
436
528
|
upgraded = dict(relation)
|
|
437
529
|
upgraded["to_id"] = candidates[0]["id"]
|
|
438
530
|
upgraded["confidence"] = "inferred" if upgraded["confidence"] == "heuristic" else upgraded["confidence"]
|
|
439
531
|
upgraded.setdefault("metadata", {})["resolution"] = "unique_symbol_name_in_codebase"
|
|
440
532
|
resolved.append(upgraded)
|
|
441
533
|
else:
|
|
534
|
+
if candidates:
|
|
535
|
+
unresolved = dict(relation)
|
|
536
|
+
unresolved.setdefault("metadata", {})["resolution"] = "ambiguous_symbol_name"
|
|
537
|
+
unresolved["metadata"]["candidate_ids"] = sorted(candidate["id"] for candidate in candidates)
|
|
538
|
+
resolved.append(unresolved)
|
|
539
|
+
continue
|
|
442
540
|
resolved.append(relation)
|
|
443
541
|
return resolved
|
|
444
542
|
|
package/src/cli.ts
CHANGED
|
@@ -11,7 +11,7 @@ import { projectSessionDir } from "./paths.ts";
|
|
|
11
11
|
function usage(): string {
|
|
12
12
|
return [
|
|
13
13
|
"Usage:",
|
|
14
|
-
" devflow-pi init [project]",
|
|
14
|
+
" devflow-pi init [project] [--scan]",
|
|
15
15
|
" devflow-pi doctor [project]",
|
|
16
16
|
" devflow-pi usage [project]",
|
|
17
17
|
" devflow-pi [project] [--resume [session-id]] [--prompt <text>] [--mcp] [--yolo]",
|
|
@@ -47,12 +47,22 @@ async function main(): Promise<void> {
|
|
|
47
47
|
return;
|
|
48
48
|
}
|
|
49
49
|
if (command === "init") {
|
|
50
|
-
const
|
|
50
|
+
const projectArgument = args[1] && !args[1].startsWith("-") ? args[1] : undefined;
|
|
51
|
+
const requested = projectArgument ? path.resolve(projectArgument) : process.cwd();
|
|
51
52
|
const root = await resolveProjectRoot(requested, { explicit: requested, allowUninitialized: true });
|
|
52
53
|
const { context, created } = await initializeProject(root);
|
|
53
54
|
console.log(`${created ? "Initialized" : "Already initialized"}: ${context.root}`);
|
|
54
55
|
console.log(`projectId: ${context.manifest.projectId}`);
|
|
56
|
+
console.log(`workspaceId: ${context.workspaceId}`);
|
|
57
|
+
console.log(`database: ${context.controlDatabasePath}`);
|
|
55
58
|
console.log(`planning: ${context.planningRoot}`);
|
|
59
|
+
if (args.includes("--scan")) {
|
|
60
|
+
const { runProjectInitialization } = await import("./project-initializer.ts");
|
|
61
|
+
const result = await runProjectInitialization(context, { requestId: option(args, "--request-id") });
|
|
62
|
+
console.log(`initializationRun: ${result.runId}`);
|
|
63
|
+
console.log(`contextSnapshot: ${result.snapshotId}`);
|
|
64
|
+
console.log(`completeness: ${result.completeness}`);
|
|
65
|
+
}
|
|
56
66
|
return;
|
|
57
67
|
}
|
|
58
68
|
const positionalProject = command && !["doctor", "usage"].includes(command) && !command.startsWith("-") ? command : undefined;
|
package/src/constants.ts
CHANGED